@optave/codegraph 3.1.5 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +3 -2
  2. package/package.json +7 -7
  3. package/src/ast-analysis/engine.js +252 -258
  4. package/src/ast-analysis/shared.js +0 -12
  5. package/src/ast-analysis/visitors/cfg-visitor.js +635 -649
  6. package/src/ast-analysis/visitors/complexity-visitor.js +135 -139
  7. package/src/ast-analysis/visitors/dataflow-visitor.js +230 -224
  8. package/src/cli/commands/ast.js +2 -1
  9. package/src/cli/commands/audit.js +2 -1
  10. package/src/cli/commands/batch.js +2 -1
  11. package/src/cli/commands/brief.js +12 -0
  12. package/src/cli/commands/cfg.js +2 -1
  13. package/src/cli/commands/check.js +20 -23
  14. package/src/cli/commands/children.js +6 -1
  15. package/src/cli/commands/complexity.js +2 -1
  16. package/src/cli/commands/context.js +6 -1
  17. package/src/cli/commands/dataflow.js +2 -1
  18. package/src/cli/commands/deps.js +8 -3
  19. package/src/cli/commands/flow.js +2 -1
  20. package/src/cli/commands/fn-impact.js +6 -1
  21. package/src/cli/commands/owners.js +4 -2
  22. package/src/cli/commands/query.js +6 -1
  23. package/src/cli/commands/roles.js +2 -1
  24. package/src/cli/commands/search.js +8 -2
  25. package/src/cli/commands/sequence.js +2 -1
  26. package/src/cli/commands/triage.js +38 -27
  27. package/src/db/connection.js +18 -12
  28. package/src/db/migrations.js +41 -64
  29. package/src/db/query-builder.js +60 -4
  30. package/src/db/repository/in-memory-repository.js +27 -16
  31. package/src/db/repository/nodes.js +8 -10
  32. package/src/domain/analysis/brief.js +155 -0
  33. package/src/domain/analysis/context.js +174 -190
  34. package/src/domain/analysis/dependencies.js +200 -146
  35. package/src/domain/analysis/exports.js +3 -2
  36. package/src/domain/analysis/impact.js +267 -152
  37. package/src/domain/analysis/module-map.js +247 -221
  38. package/src/domain/analysis/roles.js +8 -5
  39. package/src/domain/analysis/symbol-lookup.js +7 -5
  40. package/src/domain/graph/builder/helpers.js +1 -1
  41. package/src/domain/graph/builder/incremental.js +116 -90
  42. package/src/domain/graph/builder/pipeline.js +106 -80
  43. package/src/domain/graph/builder/stages/build-edges.js +318 -239
  44. package/src/domain/graph/builder/stages/detect-changes.js +198 -177
  45. package/src/domain/graph/builder/stages/insert-nodes.js +147 -139
  46. package/src/domain/graph/watcher.js +2 -2
  47. package/src/domain/parser.js +20 -11
  48. package/src/domain/queries.js +1 -0
  49. package/src/domain/search/search/filters.js +9 -5
  50. package/src/domain/search/search/keyword.js +12 -5
  51. package/src/domain/search/search/prepare.js +13 -5
  52. package/src/extractors/csharp.js +224 -207
  53. package/src/extractors/go.js +176 -172
  54. package/src/extractors/hcl.js +94 -78
  55. package/src/extractors/java.js +213 -207
  56. package/src/extractors/javascript.js +274 -304
  57. package/src/extractors/php.js +234 -221
  58. package/src/extractors/python.js +252 -250
  59. package/src/extractors/ruby.js +192 -185
  60. package/src/extractors/rust.js +182 -167
  61. package/src/features/ast.js +5 -3
  62. package/src/features/audit.js +4 -2
  63. package/src/features/boundaries.js +98 -83
  64. package/src/features/cfg.js +134 -143
  65. package/src/features/communities.js +68 -53
  66. package/src/features/complexity.js +143 -132
  67. package/src/features/dataflow.js +146 -149
  68. package/src/features/export.js +3 -3
  69. package/src/features/graph-enrichment.js +2 -2
  70. package/src/features/manifesto.js +9 -6
  71. package/src/features/owners.js +4 -3
  72. package/src/features/sequence.js +152 -141
  73. package/src/features/shared/find-nodes.js +31 -0
  74. package/src/features/structure.js +130 -99
  75. package/src/features/triage.js +83 -68
  76. package/src/graph/classifiers/risk.js +3 -2
  77. package/src/graph/classifiers/roles.js +6 -3
  78. package/src/index.js +1 -0
  79. package/src/mcp/server.js +65 -56
  80. package/src/mcp/tool-registry.js +13 -0
  81. package/src/mcp/tools/brief.js +8 -0
  82. package/src/mcp/tools/index.js +2 -0
  83. package/src/presentation/brief.js +51 -0
  84. package/src/presentation/queries-cli/exports.js +21 -14
  85. package/src/presentation/queries-cli/impact.js +55 -39
  86. package/src/presentation/queries-cli/inspect.js +184 -189
  87. package/src/presentation/queries-cli/overview.js +57 -58
  88. package/src/presentation/queries-cli/path.js +36 -29
  89. package/src/presentation/table.js +0 -8
  90. package/src/shared/generators.js +7 -3
  91. package/src/shared/kinds.js +1 -1
@@ -15,23 +15,159 @@ import {
15
15
  readFileSafe,
16
16
  } from '../helpers.js';
17
17
 
18
+ // ── Phase 1: Insert file nodes, definitions, exports ────────────────────
19
+
20
+ function insertDefinitionsAndExports(db, allSymbols) {
21
+ const phase1Rows = [];
22
+ for (const [relPath, symbols] of allSymbols) {
23
+ phase1Rows.push([relPath, 'file', relPath, 0, null, null, null, null, null]);
24
+ for (const def of symbols.definitions) {
25
+ const dotIdx = def.name.lastIndexOf('.');
26
+ const scope = dotIdx !== -1 ? def.name.slice(0, dotIdx) : null;
27
+ phase1Rows.push([
28
+ def.name,
29
+ def.kind,
30
+ relPath,
31
+ def.line,
32
+ def.endLine || null,
33
+ null,
34
+ def.name,
35
+ scope,
36
+ def.visibility || null,
37
+ ]);
38
+ }
39
+ for (const exp of symbols.exports) {
40
+ phase1Rows.push([exp.name, exp.kind, relPath, exp.line, null, null, exp.name, null, null]);
41
+ }
42
+ }
43
+ batchInsertNodes(db, phase1Rows);
44
+
45
+ // Mark exported symbols
46
+ const markExported = db.prepare(
47
+ 'UPDATE nodes SET exported = 1 WHERE name = ? AND kind = ? AND file = ? AND line = ?',
48
+ );
49
+ for (const [relPath, symbols] of allSymbols) {
50
+ for (const exp of symbols.exports) {
51
+ markExported.run(exp.name, exp.kind, relPath, exp.line);
52
+ }
53
+ }
54
+ }
55
+
56
+ // ── Phase 2: Insert children (needs parent IDs) ────────────────────────
57
+
58
+ function insertChildren(db, allSymbols) {
59
+ const childRows = [];
60
+ for (const [relPath, symbols] of allSymbols) {
61
+ const nodeIdMap = new Map();
62
+ for (const row of bulkNodeIdsByFile(db, relPath)) {
63
+ nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
64
+ }
65
+ for (const def of symbols.definitions) {
66
+ if (!def.children?.length) continue;
67
+ const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
68
+ if (!defId) continue;
69
+ for (const child of def.children) {
70
+ const qualifiedName = `${def.name}.${child.name}`;
71
+ childRows.push([
72
+ child.name,
73
+ child.kind,
74
+ relPath,
75
+ child.line,
76
+ child.endLine || null,
77
+ defId,
78
+ qualifiedName,
79
+ def.name,
80
+ child.visibility || null,
81
+ ]);
82
+ }
83
+ }
84
+ }
85
+ batchInsertNodes(db, childRows);
86
+ }
87
+
88
+ // ── Phase 3: Insert containment + parameter_of edges ────────────────────
89
+
90
+ function insertContainmentEdges(db, allSymbols) {
91
+ const edgeRows = [];
92
+ for (const [relPath, symbols] of allSymbols) {
93
+ const nodeIdMap = new Map();
94
+ for (const row of bulkNodeIdsByFile(db, relPath)) {
95
+ nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
96
+ }
97
+ const fileId = nodeIdMap.get(`${relPath}|file|0`);
98
+ for (const def of symbols.definitions) {
99
+ const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
100
+ if (fileId && defId) {
101
+ edgeRows.push([fileId, defId, 'contains', 1.0, 0]);
102
+ }
103
+ if (def.children?.length && defId) {
104
+ for (const child of def.children) {
105
+ const childId = nodeIdMap.get(`${child.name}|${child.kind}|${child.line}`);
106
+ if (childId) {
107
+ edgeRows.push([defId, childId, 'contains', 1.0, 0]);
108
+ if (child.kind === 'parameter') {
109
+ edgeRows.push([childId, defId, 'parameter_of', 1.0, 0]);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ }
116
+ batchInsertEdges(db, edgeRows);
117
+ }
118
+
119
+ // ── Phase 4: Update file hashes ─────────────────────────────────────────
120
+
121
+ function updateFileHashes(_db, allSymbols, precomputedData, metadataUpdates, rootDir, upsertHash) {
122
+ if (!upsertHash) return;
123
+
124
+ for (const [relPath] of allSymbols) {
125
+ const precomputed = precomputedData.get(relPath);
126
+ if (precomputed?._reverseDepOnly) {
127
+ // no-op: file unchanged, hash already correct
128
+ } else if (precomputed?.hash) {
129
+ const stat = precomputed.stat || fileStat(path.join(rootDir, relPath));
130
+ const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
131
+ const size = stat ? stat.size : 0;
132
+ upsertHash.run(relPath, precomputed.hash, mtime, size);
133
+ } else {
134
+ const absPath = path.join(rootDir, relPath);
135
+ let code;
136
+ try {
137
+ code = readFileSafe(absPath);
138
+ } catch {
139
+ code = null;
140
+ }
141
+ if (code !== null) {
142
+ const stat = fileStat(absPath);
143
+ const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
144
+ const size = stat ? stat.size : 0;
145
+ upsertHash.run(relPath, fileHash(code), mtime, size);
146
+ }
147
+ }
148
+ }
149
+
150
+ // Also update metadata-only entries (self-heal mtime/size without re-parse)
151
+ for (const item of metadataUpdates) {
152
+ const mtime = item.stat ? Math.floor(item.stat.mtimeMs) : 0;
153
+ const size = item.stat ? item.stat.size : 0;
154
+ upsertHash.run(item.relPath, item.hash, mtime, size);
155
+ }
156
+ }
157
+
158
+ // ── Main entry point ────────────────────────────────────────────────────
159
+
18
160
  /**
19
161
  * @param {import('../context.js').PipelineContext} ctx
20
162
  */
21
163
  export async function insertNodes(ctx) {
22
164
  const { db, allSymbols, filesToParse, metadataUpdates, rootDir, removed } = ctx;
23
165
 
24
- // Build lookup from incremental data (pre-computed hashes + stats)
25
166
  const precomputedData = new Map();
26
167
  for (const item of filesToParse) {
27
- if (item.relPath) {
28
- precomputedData.set(item.relPath, item);
29
- }
168
+ if (item.relPath) precomputedData.set(item.relPath, item);
30
169
  }
31
170
 
32
- const bulkGetNodeIds = { all: (file) => bulkNodeIdsByFile(db, file) };
33
-
34
- // Prepare hash upsert
35
171
  let upsertHash;
36
172
  try {
37
173
  upsertHash = db.prepare(
@@ -42,143 +178,15 @@ export async function insertNodes(ctx) {
42
178
  }
43
179
 
44
180
  // Populate fileSymbols before the transaction so it is a pure input
45
- // to (rather than a side-effect of) the DB write — avoids partial
46
- // population if the transaction rolls back.
47
181
  for (const [relPath, symbols] of allSymbols) {
48
182
  ctx.fileSymbols.set(relPath, symbols);
49
183
  }
50
184
 
51
185
  const insertAll = db.transaction(() => {
52
- // Phase 1: Batch insert all file nodes + definitions + exports
53
- // Row format: [name, kind, file, line, end_line, parent_id, qualified_name, scope, visibility]
54
- const phase1Rows = [];
55
- for (const [relPath, symbols] of allSymbols) {
56
- phase1Rows.push([relPath, 'file', relPath, 0, null, null, null, null, null]);
57
- for (const def of symbols.definitions) {
58
- // Methods already have 'Class.method' as name — use as qualified_name.
59
- // For methods, scope is the class portion; for top-level defs, scope is null.
60
- const dotIdx = def.name.lastIndexOf('.');
61
- const scope = dotIdx !== -1 ? def.name.slice(0, dotIdx) : null;
62
- phase1Rows.push([
63
- def.name,
64
- def.kind,
65
- relPath,
66
- def.line,
67
- def.endLine || null,
68
- null,
69
- def.name,
70
- scope,
71
- def.visibility || null,
72
- ]);
73
- }
74
- for (const exp of symbols.exports) {
75
- phase1Rows.push([exp.name, exp.kind, relPath, exp.line, null, null, exp.name, null, null]);
76
- }
77
- }
78
- batchInsertNodes(db, phase1Rows);
79
-
80
- // Phase 1b: Mark exported symbols
81
- const markExported = db.prepare(
82
- 'UPDATE nodes SET exported = 1 WHERE name = ? AND kind = ? AND file = ? AND line = ?',
83
- );
84
- for (const [relPath, symbols] of allSymbols) {
85
- for (const exp of symbols.exports) {
86
- markExported.run(exp.name, exp.kind, relPath, exp.line);
87
- }
88
- }
89
-
90
- // Phase 3: Batch insert children (needs parent IDs from Phase 2)
91
- const childRows = [];
92
- for (const [relPath, symbols] of allSymbols) {
93
- const nodeIdMap = new Map();
94
- for (const row of bulkGetNodeIds.all(relPath)) {
95
- nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
96
- }
97
- for (const def of symbols.definitions) {
98
- if (!def.children?.length) continue;
99
- const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
100
- if (!defId) continue;
101
- for (const child of def.children) {
102
- const qualifiedName = `${def.name}.${child.name}`;
103
- childRows.push([
104
- child.name,
105
- child.kind,
106
- relPath,
107
- child.line,
108
- child.endLine || null,
109
- defId,
110
- qualifiedName,
111
- def.name,
112
- child.visibility || null,
113
- ]);
114
- }
115
- }
116
- }
117
- batchInsertNodes(db, childRows);
118
-
119
- // Phase 5: Batch insert contains/parameter_of edges
120
- const edgeRows = [];
121
- for (const [relPath, symbols] of allSymbols) {
122
- const nodeIdMap = new Map();
123
- for (const row of bulkGetNodeIds.all(relPath)) {
124
- nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
125
- }
126
- const fileId = nodeIdMap.get(`${relPath}|file|0`);
127
- for (const def of symbols.definitions) {
128
- const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
129
- if (fileId && defId) {
130
- edgeRows.push([fileId, defId, 'contains', 1.0, 0]);
131
- }
132
- if (def.children?.length && defId) {
133
- for (const child of def.children) {
134
- const childId = nodeIdMap.get(`${child.name}|${child.kind}|${child.line}`);
135
- if (childId) {
136
- edgeRows.push([defId, childId, 'contains', 1.0, 0]);
137
- if (child.kind === 'parameter') {
138
- edgeRows.push([childId, defId, 'parameter_of', 1.0, 0]);
139
- }
140
- }
141
- }
142
- }
143
- }
144
-
145
- // Update file hash — skip reverse-dep files (unchanged)
146
- if (upsertHash) {
147
- const precomputed = precomputedData.get(relPath);
148
- if (precomputed?._reverseDepOnly) {
149
- // no-op: file unchanged, hash already correct
150
- } else if (precomputed?.hash) {
151
- const stat = precomputed.stat || fileStat(path.join(rootDir, relPath));
152
- const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
153
- const size = stat ? stat.size : 0;
154
- upsertHash.run(relPath, precomputed.hash, mtime, size);
155
- } else {
156
- const absPath = path.join(rootDir, relPath);
157
- let code;
158
- try {
159
- code = readFileSafe(absPath);
160
- } catch {
161
- code = null;
162
- }
163
- if (code !== null) {
164
- const stat = fileStat(absPath);
165
- const mtime = stat ? Math.floor(stat.mtimeMs) : 0;
166
- const size = stat ? stat.size : 0;
167
- upsertHash.run(relPath, fileHash(code), mtime, size);
168
- }
169
- }
170
- }
171
- }
172
- batchInsertEdges(db, edgeRows);
173
-
174
- // Also update metadata-only entries (self-heal mtime/size without re-parse)
175
- if (upsertHash) {
176
- for (const item of metadataUpdates) {
177
- const mtime = item.stat ? Math.floor(item.stat.mtimeMs) : 0;
178
- const size = item.stat ? item.stat.size : 0;
179
- upsertHash.run(item.relPath, item.hash, mtime, size);
180
- }
181
- }
186
+ insertDefinitionsAndExports(db, allSymbols);
187
+ insertChildren(db, allSymbols);
188
+ insertContainmentEdges(db, allSymbols);
189
+ updateFileHashes(db, allSymbols, precomputedData, metadataUpdates, rootDir, upsertHash);
182
190
  });
183
191
 
184
192
  const t0 = performance.now();
@@ -57,10 +57,10 @@ export async function watchProject(rootDir, opts = {}) {
57
57
  countNodes: db.prepare('SELECT COUNT(*) as c FROM nodes WHERE file = ?'),
58
58
  countEdgesForFile: null,
59
59
  findNodeInFile: db.prepare(
60
- "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') AND file = ?",
60
+ "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant') AND file = ?",
61
61
  ),
62
62
  findNodeByName: db.prepare(
63
- "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')",
63
+ "SELECT id, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')",
64
64
  ),
65
65
  listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"),
66
66
  };
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { Language, Parser, Query } from 'web-tree-sitter';
5
- import { warn } from '../infrastructure/logger.js';
5
+ import { debug, warn } from '../infrastructure/logger.js';
6
6
  import { getNative, getNativePackageVersion, loadNative } from '../infrastructure/native.js';
7
7
 
8
8
  // Re-export all extractors for backward compatibility
@@ -116,29 +116,35 @@ export async function createParsers() {
116
116
  */
117
117
  export function disposeParsers() {
118
118
  if (_cachedParsers) {
119
- for (const [, parser] of _cachedParsers) {
119
+ for (const [id, parser] of _cachedParsers) {
120
120
  if (parser && typeof parser.delete === 'function') {
121
121
  try {
122
122
  parser.delete();
123
- } catch {}
123
+ } catch (e) {
124
+ debug(`Failed to dispose parser ${id}: ${e.message}`);
125
+ }
124
126
  }
125
127
  }
126
128
  _cachedParsers = null;
127
129
  }
128
- for (const [, query] of _queryCache) {
130
+ for (const [id, query] of _queryCache) {
129
131
  if (query && typeof query.delete === 'function') {
130
132
  try {
131
133
  query.delete();
132
- } catch {}
134
+ } catch (e) {
135
+ debug(`Failed to dispose query ${id}: ${e.message}`);
136
+ }
133
137
  }
134
138
  }
135
139
  _queryCache.clear();
136
140
  if (_cachedLanguages) {
137
- for (const [, lang] of _cachedLanguages) {
141
+ for (const [id, lang] of _cachedLanguages) {
138
142
  if (lang && typeof lang.delete === 'function') {
139
143
  try {
140
144
  lang.delete();
141
- } catch {}
145
+ } catch (e) {
146
+ debug(`Failed to dispose language ${id}: ${e.message}`);
147
+ }
142
148
  }
143
149
  }
144
150
  _cachedLanguages = null;
@@ -189,14 +195,15 @@ export async function ensureWasmTrees(fileSymbols, rootDir) {
189
195
  let code;
190
196
  try {
191
197
  code = fs.readFileSync(absPath, 'utf-8');
192
- } catch {
198
+ } catch (e) {
199
+ debug(`ensureWasmTrees: cannot read ${relPath}: ${e.message}`);
193
200
  continue;
194
201
  }
195
202
  try {
196
203
  symbols._tree = parser.parse(code);
197
204
  symbols._langId = entry.id;
198
- } catch {
199
- // skip files that fail to parse
205
+ } catch (e) {
206
+ debug(`ensureWasmTrees: parse failed for ${relPath}: ${e.message}`);
200
207
  }
201
208
  }
202
209
  }
@@ -483,7 +490,9 @@ export function getActiveEngine(opts = {}) {
483
490
  if (native) {
484
491
  try {
485
492
  version = getNativePackageVersion() ?? version;
486
- } catch {}
493
+ } catch (e) {
494
+ debug(`getNativePackageVersion failed: ${e.message}`);
495
+ }
487
496
  }
488
497
  return { name, version };
489
498
  }
@@ -22,6 +22,7 @@ export {
22
22
  } from '../shared/kinds.js';
23
23
  // ── Shared utilities ─────────────────────────────────────────────────────
24
24
  export { kindIcon, normalizeSymbol } from '../shared/normalize.js';
25
+ export { briefData } from './analysis/brief.js';
25
26
  export { contextData, explainData } from './analysis/context.js';
26
27
  export { fileDepsData, fnDepsData, pathData } from './analysis/dependencies.js';
27
28
  export { exportsData } from './analysis/exports.js';
@@ -29,15 +29,19 @@ const TEST_PATTERN = /\.(test|spec)\.|__test__|__tests__|\.stories\./;
29
29
  * @param {object} opts
30
30
  * @param {string} [opts.filePattern] - Glob pattern (only applied if it contains glob chars)
31
31
  * @param {boolean} [opts.noTests] - Exclude test/spec files
32
- * @param {boolean} [opts.isGlob] - Pre-computed: does filePattern contain glob chars?
33
32
  * @returns {Array}
34
33
  */
35
34
  export function applyFilters(rows, opts = {}) {
36
35
  let filtered = rows;
37
- const isGlob =
38
- opts.isGlob !== undefined ? opts.isGlob : opts.filePattern && /[*?[\]]/.test(opts.filePattern);
39
- if (isGlob) {
40
- filtered = filtered.filter((row) => globMatch(row.file, opts.filePattern));
36
+ const fp = opts.filePattern;
37
+ const fpArr = Array.isArray(fp) ? fp : fp ? [fp] : [];
38
+ if (fpArr.length > 0) {
39
+ filtered = filtered.filter((row) =>
40
+ fpArr.some((p) => {
41
+ const patternIsGlob = /[*?[\]]/.test(p);
42
+ return patternIsGlob ? globMatch(row.file, p) : row.file.includes(p);
43
+ }),
44
+ );
41
45
  }
42
46
  if (opts.noTests) {
43
47
  filtered = filtered.filter((row) => !TEST_PATTERN.test(row.file));
@@ -1,4 +1,5 @@
1
1
  import { openReadonlyOrFail } from '../../../db/index.js';
2
+ import { buildFileConditionSQL } from '../../../db/query-builder.js';
2
3
  import { normalizeSymbol } from '../../queries.js';
3
4
  import { hasFtsIndex, sanitizeFtsQuery } from '../stores/fts5.js';
4
5
  import { applyFilters } from './filters.js';
@@ -36,10 +37,16 @@ export function ftsSearchData(query, customDbPath, opts = {}) {
36
37
  params.push(opts.kind);
37
38
  }
38
39
 
39
- const isGlob = opts.filePattern && /[*?[\]]/.test(opts.filePattern);
40
- if (opts.filePattern && !isGlob) {
41
- sql += ' AND n.file LIKE ?';
42
- params.push(`%${opts.filePattern}%`);
40
+ const fp = opts.filePattern;
41
+ const fpArr = Array.isArray(fp) ? fp : fp ? [fp] : [];
42
+ const isGlob = fpArr.length > 0 && fpArr.some((p) => /[*?[\]]/.test(p));
43
+ // For non-glob patterns, push filtering into SQL via buildFileConditionSQL
44
+ // (handles escapeLike + ESCAPE clause). Glob patterns are handled post-query
45
+ // by applyFilters.
46
+ if (fpArr.length > 0 && !isGlob) {
47
+ const fc = buildFileConditionSQL(fpArr, 'n.file');
48
+ sql += fc.sql;
49
+ params.push(...fc.params);
43
50
  }
44
51
 
45
52
  sql += ' ORDER BY rank LIMIT ?';
@@ -53,7 +60,7 @@ export function ftsSearchData(query, customDbPath, opts = {}) {
53
60
  return { results: [] };
54
61
  }
55
62
 
56
- rows = applyFilters(rows, { ...opts, isGlob });
63
+ rows = applyFilters(rows, opts);
57
64
 
58
65
  const hc = new Map();
59
66
  const results = rows.slice(0, limit).map((row) => ({
@@ -1,4 +1,5 @@
1
1
  import { openReadonlyOrFail } from '../../../db/index.js';
2
+ import { escapeLike } from '../../../db/query-builder.js';
2
3
  import { getEmbeddingCount, getEmbeddingMeta } from '../../../db/repository/embeddings.js';
3
4
  import { MODELS } from '../models.js';
4
5
  import { applyFilters } from './filters.js';
@@ -35,7 +36,9 @@ export function prepareSearch(customDbPath, opts = {}) {
35
36
  }
36
37
 
37
38
  // Pre-filter: allow filtering by kind or file pattern to reduce search space
38
- const isGlob = opts.filePattern && /[*?[\]]/.test(opts.filePattern);
39
+ const fp = opts.filePattern;
40
+ const fpArr = Array.isArray(fp) ? fp : fp ? [fp] : [];
41
+ const isGlob = fpArr.length > 0 && fpArr.some((p) => /[*?[\]]/.test(p));
39
42
  let sql = `
40
43
  SELECT e.node_id, e.vector, e.text_preview, n.name, n.kind, n.file, n.line, n.end_line, n.role
41
44
  FROM embeddings e
@@ -47,16 +50,21 @@ export function prepareSearch(customDbPath, opts = {}) {
47
50
  conditions.push('n.kind = ?');
48
51
  params.push(opts.kind);
49
52
  }
50
- if (opts.filePattern && !isGlob) {
51
- conditions.push('n.file LIKE ?');
52
- params.push(`%${opts.filePattern}%`);
53
+ if (fpArr.length > 0 && !isGlob) {
54
+ if (fpArr.length === 1) {
55
+ conditions.push("n.file LIKE ? ESCAPE '\\'");
56
+ params.push(`%${escapeLike(fpArr[0])}%`);
57
+ } else {
58
+ conditions.push(`(${fpArr.map(() => "n.file LIKE ? ESCAPE '\\'").join(' OR ')})`);
59
+ params.push(...fpArr.map((f) => `%${escapeLike(f)}%`));
60
+ }
53
61
  }
54
62
  if (conditions.length > 0) {
55
63
  sql += ` WHERE ${conditions.join(' AND ')}`;
56
64
  }
57
65
 
58
66
  let rows = db.prepare(sql).all(...params);
59
- rows = applyFilters(rows, { ...opts, isGlob });
67
+ rows = applyFilters(rows, opts);
60
68
 
61
69
  return { db, rows, modelKey, storedDim };
62
70
  } catch (err) {