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 501e4aa4..b9bf1fcb 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'; @@ -31,7 +32,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..c0cd835b 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; 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; 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 828b5623..3aee8a7e 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'; @@ -53,7 +54,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..2d15e8cf 100644 --- a/src/db/query-builder.js +++ b/src/db/query-builder.js @@ -72,6 +72,55 @@ 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') { + validateColumn(column); + 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 +220,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..47becc3a 100644 --- a/src/domain/search/search/filters.js +++ b/src/domain/search/search/filters.js @@ -29,15 +29,19 @@ 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 = {}) { let filtered = rows; - const isGlob = - opts.isGlob !== undefined ? opts.isGlob : opts.filePattern && /[*?[\]]/.test(opts.filePattern); - if (isGlob) { - filtered = filtered.filter((row) => globMatch(row.file, opts.filePattern)); + const fp = opts.filePattern; + const fpArr = Array.isArray(fp) ? fp : fp ? [fp] : []; + if (fpArr.length > 0) { + filtered = filtered.filter((row) => + fpArr.some((p) => { + const patternIsGlob = /[*?[\]]/.test(p); + return patternIsGlob ? globMatch(row.file, p) : row.file.includes(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..4a4e3ed7 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'; @@ -36,10 +37,16 @@ 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)); + // 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) { + const fc = buildFileConditionSQL(fpArr, 'n.file'); + sql += fc.sql; + params.push(...fc.params); } sql += ' ORDER BY rank LIMIT ?'; @@ -53,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 484584c5..fb1552e4 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'; @@ -35,7 +36,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,16 +50,21 @@ 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 ? ESCAPE '\\'"); + params.push(`%${escapeLike(fpArr[0])}%`); + } else { + conditions.push(`(${fpArr.map(() => "n.file LIKE ? ESCAPE '\\'").join(' OR ')})`); + params.push(...fpArr.map((f) => `%${escapeLike(f)}%`)); + } } if (conditions.length > 0) { sql += ` WHERE ${conditions.join(' AND ')}`; } let rows = db.prepare(sql).all(...params); - rows = applyFilters(rows, { ...opts, isGlob }); + rows = applyFilters(rows, opts); return { db, rows, modelKey, storedDim }; } catch (err) { 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/complexity.js b/src/features/complexity.js index 80a55377..a4a805ec 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 { debug, info } from '../infrastructure/logger.js'; import { isTestFile } from '../infrastructure/test-filter.js'; @@ -554,9 +555,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 = ?'; @@ -761,9 +763,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/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/features/shared/find-nodes.js b/src/features/shared/find-nodes.js index d888f548..e71b1eac 100644 --- a/src/features/shared/find-nodes.js +++ b/src/features/shared/find-nodes.js @@ -1,3 +1,4 @@ +import { buildFileConditionSQL } from '../../db/query-builder.js'; import { isTestFile } from '../../infrastructure/test-filter.js'; /** @@ -15,16 +16,13 @@ export function findNodes(db, name, opts = {}, defaultKinds = []) { 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/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);