From 9238eee66d7df669d45e6c44db57060d610879ac Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 05:07:17 -0600 Subject: [PATCH 1/7] fix: support repeated --file flag for multi-file scoping (#483) Make --file variadic via Commander accumulator so passing --file a.js --file b.js collects both values into an array. - Add normalizeFileFilter(), buildFileConditionSQL(), and collectFile() helpers to query-builder.js - Update NodeQuery.fileFilter() to accept string | string[] - Update all 16 CLI command definitions to use collectFile accumulator - Update all downstream SQL consumers (complexity, cfg, dataflow, manifesto, ast, roles, generators, nodes) to use buildFileConditionSQL - Update in-memory repository to handle array file filters - Update search layer (keyword, prepare, filters) for array filePattern - Add tests for normalizeFileFilter, buildFileConditionSQL, collectFile, and NodeQuery.fileFilter with arrays --- src/cli/commands/ast.js | 3 +- src/cli/commands/audit.js | 3 +- src/cli/commands/batch.js | 3 +- src/cli/commands/cfg.js | 3 +- src/cli/commands/check.js | 3 +- src/cli/commands/children.js | 7 +- src/cli/commands/complexity.js | 3 +- src/cli/commands/context.js | 7 +- src/cli/commands/dataflow.js | 3 +- src/cli/commands/flow.js | 3 +- src/cli/commands/fn-impact.js | 7 +- src/cli/commands/owners.js | 6 +- src/cli/commands/query.js | 7 +- src/cli/commands/roles.js | 3 +- src/cli/commands/search.js | 10 ++- src/cli/commands/sequence.js | 3 +- src/cli/commands/triage.js | 3 +- src/db/query-builder.js | 63 +++++++++++++++-- src/db/repository/in-memory-repository.js | 43 +++++++----- src/db/repository/nodes.js | 18 +++-- src/domain/analysis/roles.js | 13 ++-- src/domain/search/search/filters.js | 8 ++- src/domain/search/search/keyword.js | 15 ++-- src/domain/search/search/prepare.js | 15 ++-- src/features/ast.js | 8 ++- src/features/audit.js | 6 +- src/features/cfg.js | 10 ++- src/features/complexity.js | 15 ++-- src/features/dataflow.js | 10 ++- src/features/manifesto.js | 15 ++-- src/features/owners.js | 7 +- src/shared/generators.js | 10 ++- tests/unit/query-builder.test.js | 85 +++++++++++++++++++++++ 33 files changed, 319 insertions(+), 99 deletions(-) diff --git a/src/cli/commands/ast.js b/src/cli/commands/ast.js index 0ba10575..2fe4182d 100644 --- a/src/cli/commands/ast.js +++ b/src/cli/commands/ast.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { ConfigError } from '../../shared/errors.js'; export const command = { @@ -6,7 +7,7 @@ export const command = { queryOpts: true, options: [ ['-k, --kind ', 'Filter by AST node kind (call, new, string, regex, throw, await)'], - ['-f, --file ', 'Scope to file (partial match)'], + ['-f, --file ', 'Scope to file (partial match, repeatable)', collectFile], ], async execute([pattern], opts, ctx) { const { AST_NODE_KINDS, astQuery } = await import('../../ast.js'); diff --git a/src/cli/commands/audit.js b/src/cli/commands/audit.js index abc6db05..b79f65e3 100644 --- a/src/cli/commands/audit.js +++ b/src/cli/commands/audit.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { audit } from '../../presentation/audit.js'; import { explain } from '../../presentation/queries-cli.js'; @@ -10,7 +11,7 @@ export const command = { ['-d, --db ', 'Path to graph.db'], ['--quick', 'Structural summary only (skip impact analysis and health metrics)'], ['--depth ', 'Impact/explain depth', '3'], - ['-f, --file ', 'Scope to file (partial match)'], + ['-f, --file ', 'Scope to file (partial match, repeatable)', collectFile], ['-k, --kind ', 'Filter by symbol kind'], ['-T, --no-tests', 'Exclude test/spec files from results'], ['--include-tests', 'Include test/spec files (overrides excludeTests config)'], diff --git a/src/cli/commands/batch.js b/src/cli/commands/batch.js index 0beda9ce..f4cdc403 100644 --- a/src/cli/commands/batch.js +++ b/src/cli/commands/batch.js @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { BATCH_COMMANDS, multiBatchData, splitTargets } from '../../features/batch.js'; import { batch } from '../../presentation/batch.js'; @@ -12,7 +13,7 @@ export const command = { ['--from-file ', 'Read targets from file (JSON array or newline-delimited)'], ['--stdin', 'Read targets from stdin (JSON array)'], ['--depth ', 'Traversal depth passed to underlying command'], - ['-f, --file ', 'Scope to file (partial match)'], + ['-f, --file ', 'Scope to file (partial match, repeatable)', collectFile], ['-k, --kind ', 'Filter by symbol kind'], ['-T, --no-tests', 'Exclude test/spec files from results'], ['--include-tests', 'Include test/spec files (overrides excludeTests config)'], diff --git a/src/cli/commands/cfg.js b/src/cli/commands/cfg.js index 7a2fff03..a85e89ca 100644 --- a/src/cli/commands/cfg.js +++ b/src/cli/commands/cfg.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; export const command = { @@ -6,7 +7,7 @@ export const command = { queryOpts: true, options: [ ['--format ', 'Output format: text, dot, mermaid', 'text'], - ['-f, --file ', 'Scope to file (partial match)'], + ['-f, --file ', 'Scope to file (partial match, repeatable)', collectFile], ['-k, --kind ', 'Filter by symbol kind'], ], validate([_name], opts) { diff --git a/src/cli/commands/check.js b/src/cli/commands/check.js index 24cd9a63..6a604caa 100644 --- a/src/cli/commands/check.js +++ b/src/cli/commands/check.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { ConfigError } from '../../shared/errors.js'; import { config } from '../shared/options.js'; @@ -15,7 +16,7 @@ export const command = { ['--signatures', 'Assert no function declaration lines were modified'], ['--boundaries', 'Assert no cross-owner boundary violations'], ['--depth ', 'Max BFS depth for blast radius (default: 3)'], - ['-f, --file ', 'Scope to file (partial match, manifesto mode)'], + ['-f, --file ', 'Scope to file (partial match, repeatable, manifesto mode)', collectFile], ['-k, --kind ', 'Filter by symbol kind (manifesto mode)'], ['-T, --no-tests', 'Exclude test/spec files from results'], ['--include-tests', 'Include test/spec files (overrides excludeTests config)'], diff --git a/src/cli/commands/children.js b/src/cli/commands/children.js index f6b2686c..1a5a3f92 100644 --- a/src/cli/commands/children.js +++ b/src/cli/commands/children.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { children } from '../../presentation/queries-cli.js'; @@ -6,7 +7,11 @@ export const command = { description: 'List parameters, properties, and constants of a symbol', options: [ ['-d, --db ', 'Path to graph.db'], - ['-f, --file ', 'Scope search to symbols in this file (partial match)'], + [ + '-f, --file ', + 'Scope search to symbols in this file (partial match, repeatable)', + collectFile, + ], ['-k, --kind ', 'Filter to a specific symbol kind'], ['-T, --no-tests', 'Exclude test/spec files from results'], ['-j, --json', 'Output as JSON'], diff --git a/src/cli/commands/complexity.js b/src/cli/commands/complexity.js index f9a08c01..231cd710 100644 --- a/src/cli/commands/complexity.js +++ b/src/cli/commands/complexity.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; export const command = { @@ -13,7 +14,7 @@ export const command = { ], ['--above-threshold', 'Only functions exceeding warn thresholds'], ['--health', 'Show health metrics (Halstead, MI) columns'], - ['-f, --file ', 'Scope to file (partial match)'], + ['-f, --file ', 'Scope to file (partial match, repeatable)', collectFile], ['-k, --kind ', 'Filter by symbol kind'], ['-T, --no-tests', 'Exclude test/spec files from results'], ['--include-tests', 'Include test/spec files (overrides excludeTests config)'], diff --git a/src/cli/commands/context.js b/src/cli/commands/context.js index 16210f55..91ee0772 100644 --- a/src/cli/commands/context.js +++ b/src/cli/commands/context.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { context } from '../../presentation/queries-cli.js'; @@ -7,7 +8,11 @@ export const command = { queryOpts: true, options: [ ['--depth ', 'Include callee source up to N levels deep', '0'], - ['-f, --file ', 'Scope search to functions in this file (partial match)'], + [ + '-f, --file ', + 'Scope search to functions in this file (partial match, repeatable)', + collectFile, + ], ['-k, --kind ', 'Filter to a specific symbol kind'], ['--no-source', 'Metadata only (skip source extraction)'], ['--with-test-source', 'Include test source code'], diff --git a/src/cli/commands/dataflow.js b/src/cli/commands/dataflow.js index 7c22f087..e55be263 100644 --- a/src/cli/commands/dataflow.js +++ b/src/cli/commands/dataflow.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; export const command = { @@ -5,7 +6,7 @@ export const command = { description: 'Show data flow for a function: parameters, return consumers, mutations', queryOpts: true, options: [ - ['-f, --file ', 'Scope to file (partial match)'], + ['-f, --file ', 'Scope to file (partial match, repeatable)', collectFile], ['-k, --kind ', 'Filter by symbol kind'], ['--impact', 'Show data-dependent blast radius'], ['--depth ', 'Max traversal depth', '5'], diff --git a/src/cli/commands/flow.js b/src/cli/commands/flow.js index b281d302..3e161ead 100644 --- a/src/cli/commands/flow.js +++ b/src/cli/commands/flow.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; export const command = { @@ -8,7 +9,7 @@ export const command = { options: [ ['--list', 'List all entry points grouped by type'], ['--depth ', 'Max forward traversal depth', '10'], - ['-f, --file ', 'Scope to a specific file (partial match)'], + ['-f, --file ', 'Scope to a specific file (partial match, repeatable)', collectFile], ['-k, --kind ', 'Filter by symbol kind'], ], validate([name], opts) { diff --git a/src/cli/commands/fn-impact.js b/src/cli/commands/fn-impact.js index 7715e031..5d2d5943 100644 --- a/src/cli/commands/fn-impact.js +++ b/src/cli/commands/fn-impact.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { fnImpact } from '../../presentation/queries-cli.js'; @@ -7,7 +8,11 @@ export const command = { queryOpts: true, options: [ ['--depth ', 'Max transitive depth', '5'], - ['-f, --file ', 'Scope search to functions in this file (partial match)'], + [ + '-f, --file ', + 'Scope search to functions in this file (partial match, repeatable)', + collectFile, + ], ['-k, --kind ', 'Filter to a specific symbol kind'], ], validate([_name], opts) { diff --git a/src/cli/commands/owners.js b/src/cli/commands/owners.js index fee107fc..359426b3 100644 --- a/src/cli/commands/owners.js +++ b/src/cli/commands/owners.js @@ -1,3 +1,5 @@ +import { collectFile } from '../../db/query-builder.js'; + export const command = { name: 'owners [target]', description: 'Show CODEOWNERS mapping for files and functions', @@ -5,7 +7,7 @@ export const command = { ['-d, --db ', 'Path to graph.db'], ['--owner ', 'Filter to a specific owner'], ['--boundary', 'Show cross-owner boundary edges'], - ['-f, --file ', 'Scope to a specific file'], + ['-f, --file ', 'Scope to a specific file (repeatable)', collectFile], ['-k, --kind ', 'Filter by symbol kind'], ['-T, --no-tests', 'Exclude test/spec files'], ['--include-tests', 'Include test/spec files (overrides excludeTests config)'], @@ -16,7 +18,7 @@ export const command = { owners(opts.db, { owner: opts.owner, boundary: opts.boundary, - file: opts.file || target, + file: opts.file && opts.file.length > 0 ? opts.file : target, kind: opts.kind, noTests: ctx.resolveNoTests(opts), json: opts.json, diff --git a/src/cli/commands/query.js b/src/cli/commands/query.js index f23edcf5..beca45dd 100644 --- a/src/cli/commands/query.js +++ b/src/cli/commands/query.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; import { fnDeps, symbolPath } from '../../presentation/queries-cli.js'; @@ -7,7 +8,11 @@ export const command = { queryOpts: true, options: [ ['--depth ', 'Transitive caller depth', '3'], - ['-f, --file ', 'Scope search to functions in this file (partial match)'], + [ + '-f, --file ', + 'Scope search to functions in this file (partial match, repeatable)', + collectFile, + ], ['-k, --kind ', 'Filter to a specific symbol kind'], ['--path ', 'Path mode: find shortest path to '], ['--kinds ', 'Path mode: comma-separated edge kinds to follow (default: calls)'], diff --git a/src/cli/commands/roles.js b/src/cli/commands/roles.js index df756333..8677f022 100644 --- a/src/cli/commands/roles.js +++ b/src/cli/commands/roles.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { VALID_ROLES } from '../../domain/queries.js'; import { roles } from '../../presentation/queries-cli.js'; @@ -7,7 +8,7 @@ export const command = { options: [ ['-d, --db ', 'Path to graph.db'], ['--role ', `Filter by role (${VALID_ROLES.join(', ')})`], - ['-f, --file ', 'Scope to a specific file (partial match)'], + ['-f, --file ', 'Scope to a specific file (partial match, repeatable)', collectFile], ['-T, --no-tests', 'Exclude test/spec files'], ['--include-tests', 'Include test/spec files (overrides excludeTests config)'], ['-j, --json', 'Output as JSON'], diff --git a/src/cli/commands/search.js b/src/cli/commands/search.js index e8670691..2deba45e 100644 --- a/src/cli/commands/search.js +++ b/src/cli/commands/search.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { search } from '../../domain/search/index.js'; export const command = { @@ -11,7 +12,7 @@ export const command = { ['--include-tests', 'Include test/spec files (overrides excludeTests config)'], ['--min-score ', 'Minimum similarity threshold', '0.2'], ['-k, --kind ', 'Filter by kind: function, method, class'], - ['--file ', 'Filter by file path pattern'], + ['--file ', 'Filter by file path pattern (repeatable)', collectFile], ['--rrf-k ', 'RRF k parameter for multi-query ranking', '60'], ['--mode ', 'Search mode: hybrid, semantic, keyword (default: hybrid)'], ['-j, --json', 'Output as JSON'], @@ -25,6 +26,11 @@ export const command = { } }, async execute([query], opts, ctx) { + // --file collects into an array; search supports single string filePattern, + // so pass first element for single value, or join for backward compat. + const fileArr = opts.file || []; + const filePattern = + fileArr.length === 1 ? fileArr[0] : fileArr.length > 1 ? fileArr : undefined; await search(query, opts.db, { limit: parseInt(opts.limit, 10), offset: opts.offset ? parseInt(opts.offset, 10) : undefined, @@ -32,7 +38,7 @@ export const command = { minScore: parseFloat(opts.minScore), model: opts.model, kind: opts.kind, - filePattern: opts.file, + filePattern, rrfK: parseInt(opts.rrfK, 10), mode: opts.mode, json: opts.json, diff --git a/src/cli/commands/sequence.js b/src/cli/commands/sequence.js index 6bb1e167..35b8c313 100644 --- a/src/cli/commands/sequence.js +++ b/src/cli/commands/sequence.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND } from '../../domain/queries.js'; export const command = { @@ -7,7 +8,7 @@ export const command = { options: [ ['--depth ', 'Max forward traversal depth', '10'], ['--dataflow', 'Annotate with parameter names and return arrows from dataflow table'], - ['-f, --file ', 'Scope to a specific file (partial match)'], + ['-f, --file ', 'Scope to a specific file (partial match, repeatable)', collectFile], ['-k, --kind ', 'Filter by symbol kind'], ], validate([_name], opts) { diff --git a/src/cli/commands/triage.js b/src/cli/commands/triage.js index 5a8a570f..f334c957 100644 --- a/src/cli/commands/triage.js +++ b/src/cli/commands/triage.js @@ -1,3 +1,4 @@ +import { collectFile } from '../../db/query-builder.js'; import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../domain/queries.js'; import { ConfigError } from '../../shared/errors.js'; @@ -20,7 +21,7 @@ export const command = { ], ['--min-score ', 'Only show symbols with risk score >= threshold'], ['--role ', 'Filter by role (entry, core, utility, adapter, leaf, dead)'], - ['-f, --file ', 'Scope to a specific file (partial match)'], + ['-f, --file ', 'Scope to a specific file (partial match, repeatable)', collectFile], ['-k, --kind ', 'Filter by symbol kind (function, method, class)'], ['-T, --no-tests', 'Exclude test/spec files from results'], ['--include-tests', 'Include test/spec files (overrides excludeTests config)'], diff --git a/src/db/query-builder.js b/src/db/query-builder.js index 10dd1fca..fbc7dc52 100644 --- a/src/db/query-builder.js +++ b/src/db/query-builder.js @@ -72,6 +72,54 @@ export function escapeLike(s) { return s.replace(/[%_\\]/g, '\\$&'); } +/** + * Normalize a file filter value (string, string[], or falsy) into a flat array. + * Returns an empty array when the input is falsy. + * @param {string|string[]|undefined|null} file + * @returns {string[]} + */ +export function normalizeFileFilter(file) { + if (!file) return []; + return Array.isArray(file) ? file : [file]; +} + +/** + * Build a SQL condition + params for a multi-value file LIKE filter. + * Returns `{ sql: '', params: [] }` when the filter is empty. + * + * @param {string|string[]} file - One or more partial file paths + * @param {string} [column='file'] - The column name to filter on (e.g. 'n.file', 'a.file') + * @returns {{ sql: string, params: string[] }} + */ +export function buildFileConditionSQL(file, column = 'file') { + const files = normalizeFileFilter(file); + if (files.length === 0) return { sql: '', params: [] }; + if (files.length === 1) { + return { + sql: ` AND ${column} LIKE ? ESCAPE '\\'`, + params: [`%${escapeLike(files[0])}%`], + }; + } + const clauses = files.map(() => `${column} LIKE ? ESCAPE '\\'`); + return { + sql: ` AND (${clauses.join(' OR ')})`, + params: files.map((f) => `%${escapeLike(f)}%`), + }; +} + +/** + * Commander option accumulator for repeatable `--file` flag. + * Use as: `['-f, --file ', 'Scope to file (partial match, repeatable)', collectFile]` + * @param {string} val - New value from Commander + * @param {string[]} acc - Accumulated values (undefined on first call) + * @returns {string[]} + */ +export function collectFile(val, acc) { + acc = acc || []; + acc.push(val); + return acc; +} + // ─── Standalone Helpers ────────────────────────────────────────────── /** @@ -171,11 +219,18 @@ export class NodeQuery { return this; } - /** WHERE n.file LIKE ? (no-op if falsy). Escapes LIKE wildcards in the value. */ + /** WHERE n.file LIKE ? (no-op if falsy). Accepts a single string or string[]. */ fileFilter(file) { - if (!file) return this; - this.#conditions.push("n.file LIKE ? ESCAPE '\\'"); - this.#params.push(`%${escapeLike(file)}%`); + const files = normalizeFileFilter(file); + if (files.length === 0) return this; + if (files.length === 1) { + this.#conditions.push("n.file LIKE ? ESCAPE '\\'"); + this.#params.push(`%${escapeLike(files[0])}%`); + } else { + const clauses = files.map(() => "n.file LIKE ? ESCAPE '\\'"); + this.#conditions.push(`(${clauses.join(' OR ')})`); + this.#params.push(...files.map((f) => `%${escapeLike(f)}%`)); + } return this; } diff --git a/src/db/repository/in-memory-repository.js b/src/db/repository/in-memory-repository.js index 57e6592e..1607a27c 100644 --- a/src/db/repository/in-memory-repository.js +++ b/src/db/repository/in-memory-repository.js @@ -1,6 +1,6 @@ import { ConfigError } from '../../shared/errors.js'; import { CORE_SYMBOL_KINDS, EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js'; -import { escapeLike } from '../query-builder.js'; +import { escapeLike, normalizeFileFilter } from '../query-builder.js'; import { Repository } from './base.js'; /** @@ -27,6 +27,17 @@ function likeToRegex(pattern) { return new RegExp(`^${regex}$`, 'i'); } +/** + * Build a filter predicate for file matching. + * Accepts string, string[], or falsy. Returns null when no filtering needed. + */ +function buildFileFilterFn(file) { + const files = normalizeFileFilter(file); + if (files.length === 0) return null; + const regexes = files.map((f) => likeToRegex(`%${escapeLike(f)}%`)); + return (filePath) => regexes.some((re) => re.test(filePath)); +} + /** * In-memory Repository implementation backed by Maps. * No SQLite dependency — suitable for fast unit tests. @@ -121,9 +132,9 @@ export class InMemoryRepository extends Repository { if (opts.kinds) { nodes = nodes.filter((n) => opts.kinds.includes(n.kind)); } - if (opts.file) { - const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`); - nodes = nodes.filter((n) => fileRe.test(n.file)); + { + const fileFn = buildFileFilterFn(opts.file); + if (fileFn) nodes = nodes.filter((n) => fileFn(n.file)); } // Compute fan-in per node @@ -197,9 +208,9 @@ export class InMemoryRepository extends Repository { if (opts.kind) { nodes = nodes.filter((n) => n.kind === opts.kind); } - if (opts.file) { - const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`); - nodes = nodes.filter((n) => fileRe.test(n.file)); + { + const fileFn = buildFileFilterFn(opts.file); + if (fileFn) nodes = nodes.filter((n) => fileFn(n.file)); } return nodes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); @@ -208,9 +219,9 @@ export class InMemoryRepository extends Repository { findNodeByQualifiedName(qualifiedName, opts = {}) { let nodes = [...this.#nodes.values()].filter((n) => n.qualified_name === qualifiedName); - if (opts.file) { - const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`); - nodes = nodes.filter((n) => fileRe.test(n.file)); + { + const fileFn = buildFileFilterFn(opts.file); + if (fileFn) nodes = nodes.filter((n) => fileFn(n.file)); } return nodes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); @@ -248,9 +259,9 @@ export class InMemoryRepository extends Repository { !n.file.includes('.stories.'), ); } - if (opts.file) { - const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`); - nodes = nodes.filter((n) => fileRe.test(n.file)); + { + const fileFn = buildFileFilterFn(opts.file); + if (fileFn) nodes = nodes.filter((n) => fileFn(n.file)); } if (opts.role) { nodes = nodes.filter((n) => n.role === opts.role); @@ -541,9 +552,9 @@ export class InMemoryRepository extends Repository { ['function', 'method', 'class'].includes(n.kind), ); - if (opts.file) { - const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`); - nodes = nodes.filter((n) => fileRe.test(n.file)); + { + const fileFn = buildFileFilterFn(opts.file); + if (fileFn) nodes = nodes.filter((n) => fileFn(n.file)); } if (opts.pattern) { const patternRe = likeToRegex(`%${escapeLike(opts.pattern)}%`); diff --git a/src/db/repository/nodes.js b/src/db/repository/nodes.js index fbe2ddf0..ffcf6297 100644 --- a/src/db/repository/nodes.js +++ b/src/db/repository/nodes.js @@ -1,6 +1,6 @@ import { ConfigError } from '../../shared/errors.js'; import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js'; -import { escapeLike, NodeQuery } from '../query-builder.js'; +import { buildFileConditionSQL, NodeQuery } from '../query-builder.js'; import { cachedStmt } from './cached-stmt.js'; // ─── Query-builder based lookups (moved from src/db/repository.js) ───── @@ -267,10 +267,9 @@ export function findNodesByScope(db, scopeName, opts = {}) { sql += ' AND kind = ?'; params.push(opts.kind); } - if (opts.file) { - sql += " AND file LIKE ? ESCAPE '\\'"; - params.push(`%${escapeLike(opts.file)}%`); - } + const fc = buildFileConditionSQL(opts.file, 'file'); + sql += fc.sql; + params.push(...fc.params); sql += ' ORDER BY file, line'; return db.prepare(sql).all(...params); } @@ -286,12 +285,11 @@ export function findNodesByScope(db, scopeName, opts = {}) { * @returns {object[]} */ export function findNodeByQualifiedName(db, qualifiedName, opts = {}) { - if (opts.file) { + const fc = buildFileConditionSQL(opts.file, 'file'); + if (fc.sql) { return db - .prepare( - "SELECT * FROM nodes WHERE qualified_name = ? AND file LIKE ? ESCAPE '\\' ORDER BY file, line", - ) - .all(qualifiedName, `%${escapeLike(opts.file)}%`); + .prepare(`SELECT * FROM nodes WHERE qualified_name = ?${fc.sql} ORDER BY file, line`) + .all(qualifiedName, ...fc.params); } return cachedStmt( _findNodeByQualifiedNameStmt, diff --git a/src/domain/analysis/roles.js b/src/domain/analysis/roles.js index a54362fd..141a10ed 100644 --- a/src/domain/analysis/roles.js +++ b/src/domain/analysis/roles.js @@ -1,4 +1,5 @@ import { openReadonlyOrFail } from '../../db/index.js'; +import { buildFileConditionSQL } from '../../db/query-builder.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; import { normalizeSymbol } from '../../shared/normalize.js'; import { paginateResult } from '../../shared/paginate.js'; @@ -8,8 +9,6 @@ export function rolesData(customDbPath, opts = {}) { try { const noTests = opts.noTests || false; const filterRole = opts.role || null; - const filterFile = opts.file || null; - const conditions = ['role IS NOT NULL']; const params = []; @@ -17,9 +16,13 @@ export function rolesData(customDbPath, opts = {}) { conditions.push('role = ?'); params.push(filterRole); } - if (filterFile) { - conditions.push('file LIKE ?'); - params.push(`%${filterFile}%`); + { + const fc = buildFileConditionSQL(opts.file, 'file'); + if (fc.sql) { + // Strip leading ' AND ' since we're using conditions array + conditions.push(fc.sql.replace(/^ AND /, '')); + params.push(...fc.params); + } } let rows = db diff --git a/src/domain/search/search/filters.js b/src/domain/search/search/filters.js index 465e51e0..5c966728 100644 --- a/src/domain/search/search/filters.js +++ b/src/domain/search/search/filters.js @@ -34,10 +34,14 @@ const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./; */ export function applyFilters(rows, opts = {}) { let filtered = rows; + const fp = opts.filePattern; + const fpArr = Array.isArray(fp) ? fp : fp ? [fp] : []; const isGlob = - opts.isGlob !== undefined ? opts.isGlob : opts.filePattern && /[*?[\]]/.test(opts.filePattern); + opts.isGlob !== undefined + ? opts.isGlob + : fpArr.length > 0 && fpArr.some((p) => /[*?[\]]/.test(p)); if (isGlob) { - filtered = filtered.filter((row) => globMatch(row.file, opts.filePattern)); + filtered = filtered.filter((row) => fpArr.some((p) => globMatch(row.file, p))); } if (opts.noTests) { filtered = filtered.filter((row) => !TEST_PATTERN.test(row.file)); diff --git a/src/domain/search/search/keyword.js b/src/domain/search/search/keyword.js index e43c5212..368ad68d 100644 --- a/src/domain/search/search/keyword.js +++ b/src/domain/search/search/keyword.js @@ -36,10 +36,17 @@ export function ftsSearchData(query, customDbPath, opts = {}) { params.push(opts.kind); } - const isGlob = opts.filePattern && /[*?[\]]/.test(opts.filePattern); - if (opts.filePattern && !isGlob) { - sql += ' AND n.file LIKE ?'; - params.push(`%${opts.filePattern}%`); + const fp = opts.filePattern; + const fpArr = Array.isArray(fp) ? fp : fp ? [fp] : []; + const isGlob = fpArr.length > 0 && fpArr.some((p) => /[*?[\]]/.test(p)); + if (fpArr.length > 0 && !isGlob) { + if (fpArr.length === 1) { + sql += ' AND n.file LIKE ?'; + params.push(`%${fpArr[0]}%`); + } else { + sql += ` AND (${fpArr.map(() => 'n.file LIKE ?').join(' OR ')})`; + params.push(...fpArr.map((f) => `%${f}%`)); + } } sql += ' ORDER BY rank LIMIT ?'; diff --git a/src/domain/search/search/prepare.js b/src/domain/search/search/prepare.js index 484584c5..d6ccbd92 100644 --- a/src/domain/search/search/prepare.js +++ b/src/domain/search/search/prepare.js @@ -35,7 +35,9 @@ export function prepareSearch(customDbPath, opts = {}) { } // Pre-filter: allow filtering by kind or file pattern to reduce search space - const isGlob = opts.filePattern && /[*?[\]]/.test(opts.filePattern); + const fp = opts.filePattern; + const fpArr = Array.isArray(fp) ? fp : fp ? [fp] : []; + const isGlob = fpArr.length > 0 && fpArr.some((p) => /[*?[\]]/.test(p)); let sql = ` SELECT e.node_id, e.vector, e.text_preview, n.name, n.kind, n.file, n.line, n.end_line, n.role FROM embeddings e @@ -47,9 +49,14 @@ export function prepareSearch(customDbPath, opts = {}) { conditions.push('n.kind = ?'); params.push(opts.kind); } - if (opts.filePattern && !isGlob) { - conditions.push('n.file LIKE ?'); - params.push(`%${opts.filePattern}%`); + if (fpArr.length > 0 && !isGlob) { + if (fpArr.length === 1) { + conditions.push('n.file LIKE ?'); + params.push(`%${fpArr[0]}%`); + } else { + conditions.push(`(${fpArr.map(() => 'n.file LIKE ?').join(' OR ')})`); + params.push(...fpArr.map((f) => `%${f}%`)); + } } if (conditions.length > 0) { sql += ` WHERE ${conditions.join(' AND ')}`; diff --git a/src/features/ast.js b/src/features/ast.js index 6bc3a371..ad9a6270 100644 --- a/src/features/ast.js +++ b/src/features/ast.js @@ -12,6 +12,7 @@ import { buildExtensionSet } from '../ast-analysis/shared.js'; import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createAstStoreVisitor } from '../ast-analysis/visitors/ast-store-visitor.js'; import { bulkNodeIdsByFile, openReadonlyOrFail } from '../db/index.js'; +import { buildFileConditionSQL } from '../db/query-builder.js'; import { debug } from '../infrastructure/logger.js'; import { outputResult } from '../infrastructure/result-formatter.js'; import { paginateResult } from '../shared/paginate.js'; @@ -193,9 +194,10 @@ export function astQueryData(pattern, customDbPath, opts = {}) { params.push(kind); } - if (file) { - where += ' AND a.file LIKE ?'; - params.push(`%${file}%`); + { + const fc = buildFileConditionSQL(file, 'a.file'); + where += fc.sql; + params.push(...fc.params); } if (noTests) { diff --git a/src/features/audit.js b/src/features/audit.js index 7526b2c3..5cc74a6c 100644 --- a/src/features/audit.js +++ b/src/features/audit.js @@ -8,6 +8,7 @@ import path from 'node:path'; import { openReadonlyOrFail } from '../db/index.js'; +import { normalizeFileFilter } from '../db/query-builder.js'; import { bfsTransitiveCallers } from '../domain/analysis/impact.js'; import { explainData } from '../domain/queries.js'; import { loadConfig } from '../infrastructure/config.js'; @@ -100,7 +101,7 @@ function readPhase44(db, nodeId) { export function auditData(target, customDbPath, opts = {}) { const noTests = opts.noTests || false; const maxDepth = opts.depth || 3; - const file = opts.file; + const fileFilters = normalizeFileFilter(opts.file); const kind = opts.kind; // 1. Get structure via explainData @@ -109,7 +110,8 @@ export function auditData(target, customDbPath, opts = {}) { // Apply --file and --kind filters for function targets let results = explained.results; if (explained.kind === 'function') { - if (file) results = results.filter((r) => r.file.includes(file)); + if (fileFilters.length > 0) + results = results.filter((r) => fileFilters.some((f) => r.file.includes(f))); if (kind) results = results.filter((r) => r.kind === kind); } diff --git a/src/features/cfg.js b/src/features/cfg.js index e8728cab..0de53071 100644 --- a/src/features/cfg.js +++ b/src/features/cfg.js @@ -23,6 +23,7 @@ import { hasCfgTables, openReadonlyOrFail, } from '../db/index.js'; +import { buildFileConditionSQL } from '../db/query-builder.js'; import { info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { paginateResult } from '../shared/paginate.js'; @@ -278,17 +279,14 @@ function findNodes(db, name, opts = {}) { const placeholders = kinds.map(() => '?').join(', '); const params = [`%${name}%`, ...kinds]; - let fileCondition = ''; - if (opts.file) { - fileCondition = ' AND n.file LIKE ?'; - params.push(`%${opts.file}%`); - } + const fc = buildFileConditionSQL(opts.file, 'n.file'); + params.push(...fc.params); const rows = db .prepare( `SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line FROM nodes n - WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fileCondition}`, + WHERE n.name LIKE ? AND n.kind IN (${placeholders})${fc.sql}`, ) .all(...params); diff --git a/src/features/complexity.js b/src/features/complexity.js index c5cdf62e..43a10203 100644 --- a/src/features/complexity.js +++ b/src/features/complexity.js @@ -13,6 +13,7 @@ import { import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createComplexityVisitor } from '../ast-analysis/visitors/complexity-visitor.js'; import { getFunctionNodeId, openReadonlyOrFail } from '../db/index.js'; +import { buildFileConditionSQL } from '../db/query-builder.js'; import { loadConfig } from '../infrastructure/config.js'; import { info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; @@ -547,9 +548,10 @@ export function complexityData(customDbPath, opts = {}) { where += ' AND n.name LIKE ?'; params.push(`%${target}%`); } - if (fileFilter) { - where += ' AND n.file LIKE ?'; - params.push(`%${fileFilter}%`); + { + const fc = buildFileConditionSQL(fileFilter, 'n.file'); + where += fc.sql; + params.push(...fc.params); } if (kindFilter) { where += ' AND n.kind = ?'; @@ -753,9 +755,10 @@ export function* iterComplexity(customDbPath, opts = {}) { where += ' AND n.name LIKE ?'; params.push(`%${opts.target}%`); } - if (opts.file) { - where += ' AND n.file LIKE ?'; - params.push(`%${opts.file}%`); + { + const fc = buildFileConditionSQL(opts.file, 'n.file'); + where += fc.sql; + params.push(...fc.params); } if (opts.kind) { where += ' AND n.kind = ?'; diff --git a/src/features/dataflow.js b/src/features/dataflow.js index 9d0c8bcc..0c5ce3eb 100644 --- a/src/features/dataflow.js +++ b/src/features/dataflow.js @@ -20,6 +20,7 @@ import { import { walkWithVisitors } from '../ast-analysis/visitor.js'; import { createDataflowVisitor } from '../ast-analysis/visitors/dataflow-visitor.js'; import { hasDataflowTable, openReadonlyOrFail } from '../db/index.js'; +import { buildFileConditionSQL } from '../db/query-builder.js'; import { ALL_SYMBOL_KINDS, normalizeSymbol } from '../domain/queries.js'; import { info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; @@ -243,16 +244,13 @@ function findNodes(db, name, opts = {}) { const placeholders = kinds.map(() => '?').join(', '); const params = [`%${name}%`, ...kinds]; - let fileCondition = ''; - if (opts.file) { - fileCondition = ' AND file LIKE ?'; - params.push(`%${opts.file}%`); - } + const fc = buildFileConditionSQL(opts.file, 'file'); + params.push(...fc.params); const rows = db .prepare( `SELECT * FROM nodes - WHERE name LIKE ? AND kind IN (${placeholders})${fileCondition} + WHERE name LIKE ? AND kind IN (${placeholders})${fc.sql} ORDER BY file, line`, ) .all(...params); diff --git a/src/features/manifesto.js b/src/features/manifesto.js index edae49e4..d37fd296 100644 --- a/src/features/manifesto.js +++ b/src/features/manifesto.js @@ -1,4 +1,5 @@ import { openReadonlyOrFail } from '../db/index.js'; +import { buildFileConditionSQL } from '../db/query-builder.js'; import { findCycles } from '../domain/graph/cycles.js'; import { loadConfig } from '../infrastructure/config.js'; import { debug } from '../infrastructure/logger.js'; @@ -144,9 +145,10 @@ function evaluateFunctionRules(db, rules, opts, violations, ruleResults) { let where = "WHERE n.kind IN ('function','method')"; const params = []; if (opts.noTests) where += NO_TEST_SQL; - if (opts.file) { - where += ' AND n.file LIKE ?'; - params.push(`%${opts.file}%`); + { + const fc = buildFileConditionSQL(opts.file, 'n.file'); + where += fc.sql; + params.push(...fc.params); } if (opts.kind) { where += ' AND n.kind = ?'; @@ -221,9 +223,10 @@ function evaluateFileRules(db, rules, opts, violations, ruleResults) { let where = "WHERE n.kind = 'file'"; const params = []; if (opts.noTests) where += NO_TEST_SQL; - if (opts.file) { - where += ' AND n.file LIKE ?'; - params.push(`%${opts.file}%`); + { + const fc = buildFileConditionSQL(opts.file, 'n.file'); + where += fc.sql; + params.push(...fc.params); } let rows; diff --git a/src/features/owners.js b/src/features/owners.js index 58509ff9..3608d947 100644 --- a/src/features/owners.js +++ b/src/features/owners.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { findDbPath, openReadonlyOrFail } from '../db/index.js'; +import { normalizeFileFilter } from '../db/query-builder.js'; import { isTestFile } from '../infrastructure/test-filter.js'; // ─── CODEOWNERS Parsing ────────────────────────────────────────────── @@ -192,9 +193,9 @@ export function ownersData(customDbPath, opts = {}) { .map((r) => r.file); if (opts.noTests) allFiles = allFiles.filter((f) => !isTestFile(f)); - if (opts.file) { - const filter = opts.file; - allFiles = allFiles.filter((f) => f.includes(filter)); + const fileFilters = normalizeFileFilter(opts.file); + if (fileFilters.length > 0) { + allFiles = allFiles.filter((f) => fileFilters.some((filter) => f.includes(filter))); } // Map files to owners diff --git a/src/shared/generators.js b/src/shared/generators.js index 3d121f81..75a132ea 100644 --- a/src/shared/generators.js +++ b/src/shared/generators.js @@ -1,4 +1,5 @@ import { iterateFunctionNodes, openReadonlyOrFail } from '../db/index.js'; +import { buildFileConditionSQL } from '../db/query-builder.js'; import { isTestFile } from '../infrastructure/test-filter.js'; import { ALL_SYMBOL_KINDS } from './kinds.js'; @@ -52,9 +53,12 @@ export function* iterRoles(customDbPath, opts = {}) { conditions.push('role = ?'); params.push(opts.role); } - if (opts.file) { - conditions.push('file LIKE ?'); - params.push(`%${opts.file}%`); + { + const fc = buildFileConditionSQL(opts.file, 'file'); + if (fc.sql) { + conditions.push(fc.sql.replace(/^ AND /, '')); + params.push(...fc.params); + } } const stmt = db.prepare( diff --git a/tests/unit/query-builder.test.js b/tests/unit/query-builder.test.js index 7c47285d..a2a15e51 100644 --- a/tests/unit/query-builder.test.js +++ b/tests/unit/query-builder.test.js @@ -2,10 +2,13 @@ import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { initSchema } from '../../src/db/migrations.js'; import { + buildFileConditionSQL, + collectFile, fanInJoinSQL, fanOutJoinSQL, kindInClause, NodeQuery, + normalizeFileFilter, testFilterSQL, } from '../../src/db/query-builder.js'; @@ -87,6 +90,71 @@ describe('fanOutJoinSQL', () => { }); }); +// ─── normalizeFileFilter ───────────────────────────────────────────── + +describe('normalizeFileFilter', () => { + it('returns empty array for falsy input', () => { + expect(normalizeFileFilter(null)).toEqual([]); + expect(normalizeFileFilter(undefined)).toEqual([]); + expect(normalizeFileFilter('')).toEqual([]); + }); + + it('wraps a single string in an array', () => { + expect(normalizeFileFilter('foo.js')).toEqual(['foo.js']); + }); + + it('passes through an array unchanged', () => { + expect(normalizeFileFilter(['a.js', 'b.js'])).toEqual(['a.js', 'b.js']); + }); +}); + +// ─── buildFileConditionSQL ────────────────────────────────────────── + +describe('buildFileConditionSQL', () => { + it('returns empty sql/params for falsy input', () => { + expect(buildFileConditionSQL(null)).toEqual({ sql: '', params: [] }); + expect(buildFileConditionSQL(undefined)).toEqual({ sql: '', params: [] }); + }); + + it('builds single-value LIKE clause', () => { + const { sql, params } = buildFileConditionSQL('foo'); + expect(sql).toContain('LIKE ?'); + expect(sql).toContain('ESCAPE'); + expect(params).toEqual(['%foo%']); + }); + + it('builds multi-value OR clause', () => { + const { sql, params } = buildFileConditionSQL(['foo', 'bar']); + expect(sql).toContain('OR'); + expect(sql).toMatch(/LIKE \?.*OR.*LIKE \?/); + expect(params).toEqual(['%foo%', '%bar%']); + }); + + it('uses custom column name', () => { + const { sql } = buildFileConditionSQL('foo', 'n.file'); + expect(sql).toContain('n.file LIKE'); + }); + + it('escapes LIKE wildcards', () => { + const { params } = buildFileConditionSQL('file_name%'); + expect(params[0]).toBe('%file\\_name\\%%'); + }); +}); + +// ─── collectFile ──────────────────────────────────────────────────── + +describe('collectFile', () => { + it('creates array on first call', () => { + expect(collectFile('a.js', undefined)).toEqual(['a.js']); + }); + + it('accumulates values on subsequent calls', () => { + let acc = collectFile('a.js', undefined); + acc = collectFile('b.js', acc); + expect(acc).toEqual(['a.js', 'b.js']); + }); +}); + // ─── NodeQuery ─────────────────────────────────────────────────────── describe('NodeQuery', () => { @@ -163,6 +231,23 @@ describe('NodeQuery', () => { expect(rows.length).toBe(0); }); + it('.fileFilter() accepts an array of paths', () => { + const rows = new NodeQuery().fileFilter(['foo', 'bar']).all(db); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((r) => r.file.includes('foo') || r.file.includes('bar'))).toBe(true); + // Should include both foo and bar files + const files = new Set(rows.map((r) => r.file)); + expect(files.has('src/foo.js')).toBe(true); + expect(files.has('src/bar.js')).toBe(true); + }); + + it('.fileFilter() with single-element array works like string', () => { + const arrayRows = new NodeQuery().fileFilter(['foo']).all(db); + const stringRows = new NodeQuery().fileFilter('foo').all(db); + expect(arrayRows.length).toBe(stringRows.length); + expect(arrayRows.map((r) => r.name).sort()).toEqual(stringRows.map((r) => r.name).sort()); + }); + it('.kindFilter() filters by exact kind', () => { const rows = new NodeQuery().kindFilter('class').all(db); expect(rows.length).toBe(1); From aff787cdc15c3800663f6b2bce486e6e21d69bae Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 05:23:50 -0600 Subject: [PATCH 2/7] fix: check isGlob per-pattern in applyFilters When a user mixes glob and non-glob patterns, the previous code used a single isGlob flag for the whole array. This caused plain substring patterns to go through globMatch (anchored regex), silently breaking non-glob matches. Now each pattern individually decides whether to use glob matching or substring matching. Impact: 1 functions changed, 1 affected --- src/domain/search/search/filters.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/domain/search/search/filters.js b/src/domain/search/search/filters.js index 5c966728..a58d0e57 100644 --- a/src/domain/search/search/filters.js +++ b/src/domain/search/search/filters.js @@ -36,12 +36,13 @@ export function applyFilters(rows, opts = {}) { let filtered = rows; const fp = opts.filePattern; const fpArr = Array.isArray(fp) ? fp : fp ? [fp] : []; - const isGlob = - opts.isGlob !== undefined - ? opts.isGlob - : fpArr.length > 0 && fpArr.some((p) => /[*?[\]]/.test(p)); - if (isGlob) { - filtered = filtered.filter((row) => fpArr.some((p) => globMatch(row.file, p))); + if (fpArr.length > 0) { + filtered = filtered.filter((row) => + fpArr.some((p) => { + const patternIsGlob = opts.isGlob !== undefined ? opts.isGlob : /[*?[\]]/.test(p); + return patternIsGlob ? globMatch(row.file, p) : row.file.includes(p); + }), + ); } if (opts.noTests) { filtered = filtered.filter((row) => !TEST_PATTERN.test(row.file)); From f6a0766e0ccb1cc63a836b996972e218f39d4df8 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 05:24:00 -0600 Subject: [PATCH 3/7] fix: validate column parameter in buildFileConditionSQL Add validateColumn() call at the start of buildFileConditionSQL to prevent unvalidated SQL interpolation, matching the pattern used by testFilterSQL. Impact: 1 functions changed, 1 affected --- src/db/query-builder.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/db/query-builder.js b/src/db/query-builder.js index fbc7dc52..2d15e8cf 100644 --- a/src/db/query-builder.js +++ b/src/db/query-builder.js @@ -92,6 +92,7 @@ export function normalizeFileFilter(file) { * @returns {{ sql: string, params: string[] }} */ export function buildFileConditionSQL(file, column = 'file') { + validateColumn(column); const files = normalizeFileFilter(file); if (files.length === 0) return { sql: '', params: [] }; if (files.length === 1) { From f6edc1342aca6fbf7eef80bb46cc12b952298aec Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 05:24:10 -0600 Subject: [PATCH 4/7] fix: use buildFileConditionSQL in keyword search for proper LIKE escaping The inline LIKE filter was missing escapeLike() and the ESCAPE '\' clause. Refactored to reuse buildFileConditionSQL which handles both correctly. Impact: 1 functions changed, 0 affected --- src/domain/search/search/keyword.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/domain/search/search/keyword.js b/src/domain/search/search/keyword.js index 368ad68d..f156ef84 100644 --- a/src/domain/search/search/keyword.js +++ b/src/domain/search/search/keyword.js @@ -1,4 +1,5 @@ import { openReadonlyOrFail } from '../../../db/index.js'; +import { buildFileConditionSQL } from '../../../db/query-builder.js'; import { normalizeSymbol } from '../../queries.js'; import { hasFtsIndex, sanitizeFtsQuery } from '../stores/fts5.js'; import { applyFilters } from './filters.js'; @@ -39,14 +40,13 @@ export function ftsSearchData(query, customDbPath, opts = {}) { const fp = opts.filePattern; const fpArr = Array.isArray(fp) ? fp : fp ? [fp] : []; const isGlob = fpArr.length > 0 && fpArr.some((p) => /[*?[\]]/.test(p)); + // For non-glob patterns, push filtering into SQL via buildFileConditionSQL + // (handles escapeLike + ESCAPE clause). Glob patterns are handled post-query + // by applyFilters. if (fpArr.length > 0 && !isGlob) { - if (fpArr.length === 1) { - sql += ' AND n.file LIKE ?'; - params.push(`%${fpArr[0]}%`); - } else { - sql += ` AND (${fpArr.map(() => 'n.file LIKE ?').join(' OR ')})`; - params.push(...fpArr.map((f) => `%${f}%`)); - } + const fc = buildFileConditionSQL(fpArr, 'n.file'); + sql += fc.sql; + params.push(...fc.params); } sql += ' ORDER BY rank LIMIT ?'; From bb07f131f19c8539ff4ee632143066a4d7973a7e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 05:24:21 -0600 Subject: [PATCH 5/7] fix: correct misleading comment about array joining in search command The comment said "join for backward compat" but the code actually passes the raw array for multi-file scoping. Updated to match reality. Impact: 1 functions changed, 0 affected --- src/cli/commands/search.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/search.js b/src/cli/commands/search.js index 2deba45e..c0cd835b 100644 --- a/src/cli/commands/search.js +++ b/src/cli/commands/search.js @@ -26,8 +26,8 @@ export const command = { } }, async execute([query], opts, ctx) { - // --file collects into an array; search supports single string filePattern, - // so pass first element for single value, or join for backward compat. + // --file collects into an array; pass single element unwrapped for single + // value, or pass the raw array for multi-file scoping. const fileArr = opts.file || []; const filePattern = fileArr.length === 1 ? fileArr[0] : fileArr.length > 1 ? fileArr : undefined; From 8b98e4054eb573893e10a5824d96c7fe71f44e84 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 05:38:01 -0600 Subject: [PATCH 6/7] fix: use escapeLike and ESCAPE clause in prepare.js file filter Impact: 1 functions changed, 7 affected --- src/domain/search/search/prepare.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/domain/search/search/prepare.js b/src/domain/search/search/prepare.js index d6ccbd92..785b953f 100644 --- a/src/domain/search/search/prepare.js +++ b/src/domain/search/search/prepare.js @@ -1,4 +1,5 @@ import { openReadonlyOrFail } from '../../../db/index.js'; +import { escapeLike } from '../../../db/query-builder.js'; import { getEmbeddingCount, getEmbeddingMeta } from '../../../db/repository/embeddings.js'; import { MODELS } from '../models.js'; import { applyFilters } from './filters.js'; @@ -51,11 +52,11 @@ export function prepareSearch(customDbPath, opts = {}) { } if (fpArr.length > 0 && !isGlob) { if (fpArr.length === 1) { - conditions.push('n.file LIKE ?'); - params.push(`%${fpArr[0]}%`); + conditions.push("n.file LIKE ? ESCAPE '\\'"); + params.push(`%${escapeLike(fpArr[0])}%`); } else { - conditions.push(`(${fpArr.map(() => 'n.file LIKE ?').join(' OR ')})`); - params.push(...fpArr.map((f) => `%${f}%`)); + conditions.push(`(${fpArr.map(() => "n.file LIKE ? ESCAPE '\\'").join(' OR ')})`); + params.push(...fpArr.map((f) => `%${escapeLike(f)}%`)); } } if (conditions.length > 0) { From 23db20bc002e9874c555127bf696a750c5f5461b Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Tue, 17 Mar 2026 06:08:27 -0600 Subject: [PATCH 7/7] fix: use per-pattern glob detection in search file filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop passing a pre-computed isGlob boolean to applyFilters — it applied one flag to all patterns, so mixed glob + non-glob patterns (e.g. --file "*.js" --file "utils") would treat non-glob patterns as globs. Now each pattern is individually checked for glob chars. Impact: 3 functions changed, 2 affected --- src/domain/search/search/filters.js | 3 +-- src/domain/search/search/keyword.js | 2 +- src/domain/search/search/prepare.js | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/domain/search/search/filters.js b/src/domain/search/search/filters.js index a58d0e57..47becc3a 100644 --- a/src/domain/search/search/filters.js +++ b/src/domain/search/search/filters.js @@ -29,7 +29,6 @@ const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./; * @param {object} opts * @param {string} [opts.filePattern] - Glob pattern (only applied if it contains glob chars) * @param {boolean} [opts.noTests] - Exclude test/spec files - * @param {boolean} [opts.isGlob] - Pre-computed: does filePattern contain glob chars? * @returns {Array} */ export function applyFilters(rows, opts = {}) { @@ -39,7 +38,7 @@ export function applyFilters(rows, opts = {}) { if (fpArr.length > 0) { filtered = filtered.filter((row) => fpArr.some((p) => { - const patternIsGlob = opts.isGlob !== undefined ? opts.isGlob : /[*?[\]]/.test(p); + const patternIsGlob = /[*?[\]]/.test(p); return patternIsGlob ? globMatch(row.file, p) : row.file.includes(p); }), ); diff --git a/src/domain/search/search/keyword.js b/src/domain/search/search/keyword.js index f156ef84..4a4e3ed7 100644 --- a/src/domain/search/search/keyword.js +++ b/src/domain/search/search/keyword.js @@ -60,7 +60,7 @@ export function ftsSearchData(query, customDbPath, opts = {}) { return { results: [] }; } - rows = applyFilters(rows, { ...opts, isGlob }); + rows = applyFilters(rows, opts); const hc = new Map(); const results = rows.slice(0, limit).map((row) => ({ diff --git a/src/domain/search/search/prepare.js b/src/domain/search/search/prepare.js index 785b953f..fb1552e4 100644 --- a/src/domain/search/search/prepare.js +++ b/src/domain/search/search/prepare.js @@ -64,7 +64,7 @@ export function prepareSearch(customDbPath, opts = {}) { } let rows = db.prepare(sql).all(...params); - rows = applyFilters(rows, { ...opts, isGlob }); + rows = applyFilters(rows, opts); return { db, rows, modelKey, storedDim }; } catch (err) {