@optave/codegraph 3.1.4 → 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 (210) hide show
  1. package/README.md +29 -72
  2. package/package.json +10 -8
  3. package/src/ast-analysis/engine.js +260 -246
  4. package/src/ast-analysis/shared.js +2 -14
  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 +4 -7
  9. package/src/cli/commands/audit.js +11 -11
  10. package/src/cli/commands/batch.js +6 -5
  11. package/src/cli/commands/branch-compare.js +1 -1
  12. package/src/cli/commands/brief.js +12 -0
  13. package/src/cli/commands/build.js +1 -1
  14. package/src/cli/commands/cfg.js +5 -8
  15. package/src/cli/commands/check.js +28 -36
  16. package/src/cli/commands/children.js +9 -7
  17. package/src/cli/commands/co-change.js +5 -3
  18. package/src/cli/commands/communities.js +2 -6
  19. package/src/cli/commands/complexity.js +5 -3
  20. package/src/cli/commands/context.js +9 -8
  21. package/src/cli/commands/cycles.js +12 -8
  22. package/src/cli/commands/dataflow.js +5 -8
  23. package/src/cli/commands/deps.js +9 -8
  24. package/src/cli/commands/diff-impact.js +2 -6
  25. package/src/cli/commands/embed.js +1 -1
  26. package/src/cli/commands/export.js +34 -31
  27. package/src/cli/commands/exports.js +2 -6
  28. package/src/cli/commands/flow.js +5 -8
  29. package/src/cli/commands/fn-impact.js +9 -8
  30. package/src/cli/commands/impact.js +2 -6
  31. package/src/cli/commands/info.js +2 -2
  32. package/src/cli/commands/map.js +1 -1
  33. package/src/cli/commands/mcp.js +1 -1
  34. package/src/cli/commands/models.js +1 -1
  35. package/src/cli/commands/owners.js +5 -3
  36. package/src/cli/commands/path.js +2 -2
  37. package/src/cli/commands/plot.js +40 -31
  38. package/src/cli/commands/query.js +9 -8
  39. package/src/cli/commands/registry.js +2 -2
  40. package/src/cli/commands/roles.js +5 -8
  41. package/src/cli/commands/search.js +9 -3
  42. package/src/cli/commands/sequence.js +5 -8
  43. package/src/cli/commands/snapshot.js +6 -1
  44. package/src/cli/commands/stats.js +1 -1
  45. package/src/cli/commands/structure.js +5 -4
  46. package/src/cli/commands/triage.js +41 -30
  47. package/src/cli/commands/watch.js +1 -1
  48. package/src/cli/commands/where.js +2 -6
  49. package/src/cli/index.js +11 -5
  50. package/src/cli/shared/open-graph.js +13 -0
  51. package/src/cli/shared/options.js +22 -2
  52. package/src/cli.js +1 -1
  53. package/src/db/connection.js +140 -11
  54. package/src/{db.js → db/index.js} +12 -5
  55. package/src/db/migrations.js +42 -65
  56. package/src/db/query-builder.js +72 -9
  57. package/src/db/repository/base.js +1 -1
  58. package/src/db/repository/graph-read.js +3 -3
  59. package/src/db/repository/in-memory-repository.js +30 -28
  60. package/src/db/repository/nodes.js +10 -17
  61. package/src/domain/analysis/brief.js +155 -0
  62. package/src/domain/analysis/context.js +392 -0
  63. package/src/domain/analysis/dependencies.js +395 -0
  64. package/src/{analysis → domain/analysis}/exports.js +11 -6
  65. package/src/domain/analysis/impact.js +581 -0
  66. package/src/domain/analysis/module-map.js +348 -0
  67. package/src/{analysis → domain/analysis}/roles.js +12 -9
  68. package/src/{analysis → domain/analysis}/symbol-lookup.js +19 -11
  69. package/src/{builder → domain/graph/builder}/helpers.js +4 -4
  70. package/src/{builder → domain/graph/builder}/incremental.js +119 -93
  71. package/src/domain/graph/builder/pipeline.js +156 -0
  72. package/src/domain/graph/builder/stages/build-edges.js +376 -0
  73. package/src/{builder → domain/graph/builder}/stages/build-structure.js +4 -4
  74. package/src/{builder → domain/graph/builder}/stages/collect-files.js +2 -2
  75. package/src/{builder → domain/graph/builder}/stages/detect-changes.js +204 -183
  76. package/src/{builder → domain/graph/builder}/stages/finalize.js +4 -4
  77. package/src/domain/graph/builder/stages/insert-nodes.js +203 -0
  78. package/src/{builder → domain/graph/builder}/stages/parse-files.js +2 -2
  79. package/src/{builder → domain/graph/builder}/stages/resolve-imports.js +1 -1
  80. package/src/{builder → domain/graph/builder}/stages/run-analyses.js +2 -2
  81. package/src/{change-journal.js → domain/graph/change-journal.js} +1 -1
  82. package/src/{cycles.js → domain/graph/cycles.js} +4 -4
  83. package/src/{journal.js → domain/graph/journal.js} +1 -1
  84. package/src/{resolve.js → domain/graph/resolve.js} +2 -2
  85. package/src/{watcher.js → domain/graph/watcher.js} +7 -7
  86. package/src/{parser.js → domain/parser.js} +24 -15
  87. package/src/{queries.js → domain/queries.js} +17 -16
  88. package/src/{embeddings → domain/search}/generator.js +3 -3
  89. package/src/{embeddings → domain/search}/models.js +2 -2
  90. package/src/{embeddings → domain/search}/search/cli-formatter.js +1 -1
  91. package/src/{embeddings → domain/search}/search/filters.js +9 -5
  92. package/src/{embeddings → domain/search}/search/hybrid.js +1 -1
  93. package/src/{embeddings → domain/search}/search/keyword.js +13 -6
  94. package/src/{embeddings → domain/search}/search/prepare.js +15 -7
  95. package/src/{embeddings → domain/search}/search/semantic.js +1 -1
  96. package/src/{embeddings → domain/search}/strategies/structured.js +1 -1
  97. package/src/extractors/csharp.js +224 -207
  98. package/src/extractors/go.js +176 -172
  99. package/src/extractors/hcl.js +94 -78
  100. package/src/extractors/java.js +213 -207
  101. package/src/extractors/javascript.js +275 -305
  102. package/src/extractors/php.js +234 -221
  103. package/src/extractors/python.js +252 -250
  104. package/src/extractors/ruby.js +192 -185
  105. package/src/extractors/rust.js +182 -167
  106. package/src/{ast.js → features/ast.js} +13 -11
  107. package/src/{audit.js → features/audit.js} +20 -46
  108. package/src/{batch.js → features/batch.js} +5 -5
  109. package/src/{boundaries.js → features/boundaries.js} +100 -85
  110. package/src/{branch-compare.js → features/branch-compare.js} +3 -3
  111. package/src/{cfg.js → features/cfg.js} +141 -150
  112. package/src/{check.js → features/check.js} +13 -30
  113. package/src/{cochange.js → features/cochange.js} +5 -5
  114. package/src/{communities.js → features/communities.js} +72 -57
  115. package/src/{complexity.js → features/complexity.js} +154 -143
  116. package/src/{dataflow.js → features/dataflow.js} +155 -158
  117. package/src/{export.js → features/export.js} +6 -6
  118. package/src/{flow.js → features/flow.js} +4 -4
  119. package/src/{viewer.js → features/graph-enrichment.js} +8 -8
  120. package/src/{manifesto.js → features/manifesto.js} +15 -12
  121. package/src/{owners.js → features/owners.js} +6 -5
  122. package/src/features/sequence.js +300 -0
  123. package/src/features/shared/find-nodes.js +31 -0
  124. package/src/{snapshot.js → features/snapshot.js} +3 -3
  125. package/src/{structure.js → features/structure.js} +139 -108
  126. package/src/features/triage.js +141 -0
  127. package/src/graph/builders/dependency.js +33 -14
  128. package/src/graph/classifiers/risk.js +3 -2
  129. package/src/graph/classifiers/roles.js +6 -3
  130. package/src/index.cjs +16 -0
  131. package/src/index.js +40 -39
  132. package/src/{native.js → infrastructure/native.js} +1 -1
  133. package/src/mcp/middleware.js +1 -1
  134. package/src/mcp/server.js +68 -59
  135. package/src/mcp/tool-registry.js +15 -2
  136. package/src/mcp/tools/ast-query.js +1 -1
  137. package/src/mcp/tools/audit.js +1 -1
  138. package/src/mcp/tools/batch-query.js +1 -1
  139. package/src/mcp/tools/branch-compare.js +3 -1
  140. package/src/mcp/tools/brief.js +8 -0
  141. package/src/mcp/tools/cfg.js +1 -1
  142. package/src/mcp/tools/check.js +3 -3
  143. package/src/mcp/tools/co-changes.js +1 -1
  144. package/src/mcp/tools/code-owners.js +1 -1
  145. package/src/mcp/tools/communities.js +1 -1
  146. package/src/mcp/tools/complexity.js +1 -1
  147. package/src/mcp/tools/dataflow.js +2 -2
  148. package/src/mcp/tools/execution-flow.js +2 -2
  149. package/src/mcp/tools/export-graph.js +2 -2
  150. package/src/mcp/tools/find-cycles.js +2 -2
  151. package/src/mcp/tools/index.js +2 -0
  152. package/src/mcp/tools/list-repos.js +1 -1
  153. package/src/mcp/tools/sequence.js +1 -1
  154. package/src/mcp/tools/structure.js +1 -1
  155. package/src/mcp/tools/triage.js +2 -2
  156. package/src/{commands → presentation}/audit.js +2 -2
  157. package/src/{commands → presentation}/batch.js +1 -1
  158. package/src/{commands → presentation}/branch-compare.js +2 -2
  159. package/src/presentation/brief.js +51 -0
  160. package/src/{commands → presentation}/cfg.js +1 -1
  161. package/src/{commands → presentation}/check.js +2 -2
  162. package/src/{commands → presentation}/communities.js +1 -1
  163. package/src/{commands → presentation}/complexity.js +1 -1
  164. package/src/{commands → presentation}/dataflow.js +1 -1
  165. package/src/{commands → presentation}/flow.js +2 -2
  166. package/src/{commands → presentation}/manifesto.js +1 -1
  167. package/src/{commands → presentation}/owners.js +1 -1
  168. package/src/presentation/queries-cli/exports.js +53 -0
  169. package/src/presentation/queries-cli/impact.js +214 -0
  170. package/src/presentation/queries-cli/index.js +5 -0
  171. package/src/presentation/queries-cli/inspect.js +329 -0
  172. package/src/presentation/queries-cli/overview.js +196 -0
  173. package/src/presentation/queries-cli/path.js +65 -0
  174. package/src/presentation/queries-cli.js +27 -0
  175. package/src/{commands → presentation}/query.js +1 -1
  176. package/src/presentation/result-formatter.js +126 -3
  177. package/src/{commands → presentation}/sequence.js +2 -2
  178. package/src/{commands → presentation}/structure.js +1 -1
  179. package/src/presentation/table.js +0 -8
  180. package/src/{commands → presentation}/triage.js +1 -1
  181. package/src/{constants.js → shared/constants.js} +1 -1
  182. package/src/shared/file-utils.js +2 -2
  183. package/src/shared/generators.js +9 -5
  184. package/src/shared/hierarchy.js +1 -1
  185. package/src/{kinds.js → shared/kinds.js} +1 -1
  186. package/src/analysis/context.js +0 -408
  187. package/src/analysis/dependencies.js +0 -341
  188. package/src/analysis/impact.js +0 -463
  189. package/src/analysis/module-map.js +0 -322
  190. package/src/builder/pipeline.js +0 -130
  191. package/src/builder/stages/build-edges.js +0 -297
  192. package/src/builder/stages/insert-nodes.js +0 -195
  193. package/src/mcp.js +0 -2
  194. package/src/queries-cli.js +0 -866
  195. package/src/sequence.js +0 -289
  196. package/src/triage.js +0 -126
  197. /package/src/{builder → domain/graph/builder}/context.js +0 -0
  198. /package/src/{builder.js → domain/graph/builder.js} +0 -0
  199. /package/src/{embeddings → domain/search}/index.js +0 -0
  200. /package/src/{embeddings → domain/search}/stores/fts5.js +0 -0
  201. /package/src/{embeddings → domain/search}/stores/sqlite-blob.js +0 -0
  202. /package/src/{embeddings → domain/search}/strategies/source.js +0 -0
  203. /package/src/{embeddings → domain/search}/strategies/text-utils.js +0 -0
  204. /package/src/{config.js → infrastructure/config.js} +0 -0
  205. /package/src/{logger.js → infrastructure/logger.js} +0 -0
  206. /package/src/{registry.js → infrastructure/registry.js} +0 -0
  207. /package/src/{update-check.js → infrastructure/update-check.js} +0 -0
  208. /package/src/{commands → presentation}/cochange.js +0 -0
  209. /package/src/{errors.js → shared/errors.js} +0 -0
  210. /package/src/{paginate.js → shared/paginate.js} +0 -0
@@ -1,14 +1,69 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import Database from 'better-sqlite3';
4
- import { DbError } from '../errors.js';
5
- import { warn } from '../logger.js';
5
+ import { debug, warn } from '../infrastructure/logger.js';
6
+ import { DbError } from '../shared/errors.js';
7
+ import { Repository } from './repository/base.js';
8
+ import { SqliteRepository } from './repository/sqlite-repository.js';
9
+
10
+ let _cachedRepoRoot; // undefined = not computed, null = not a git repo
11
+ let _cachedRepoRootCwd; // cwd at the time the cache was populated
12
+
13
+ /**
14
+ * Return the git worktree/repo root for the given directory (or cwd).
15
+ * Uses `git rev-parse --show-toplevel` which returns the correct root
16
+ * for both regular repos and git worktrees.
17
+ * Results are cached per-process when called without arguments.
18
+ * The cache is keyed on cwd so it invalidates if the working directory changes
19
+ * (e.g. MCP server serving multiple sessions).
20
+ * @param {string} [fromDir] - Directory to resolve from (defaults to cwd)
21
+ * @returns {string | null} Absolute path to repo root, or null if not in a git repo
22
+ */
23
+ export function findRepoRoot(fromDir) {
24
+ const dir = fromDir || process.cwd();
25
+ if (!fromDir && _cachedRepoRoot !== undefined && _cachedRepoRootCwd === dir) {
26
+ return _cachedRepoRoot;
27
+ }
28
+ let root = null;
29
+ try {
30
+ const raw = execFileSync('git', ['rev-parse', '--show-toplevel'], {
31
+ cwd: dir,
32
+ encoding: 'utf-8',
33
+ stdio: ['pipe', 'pipe', 'pipe'],
34
+ }).trim();
35
+ // Use realpathSync to resolve symlinks (macOS /var → /private/var) and
36
+ // 8.3 short names (Windows RUNNER~1 → runneradmin) so the ceiling path
37
+ // matches the realpathSync'd dir in findDbPath.
38
+ try {
39
+ root = fs.realpathSync(raw);
40
+ } catch (e) {
41
+ debug(`realpathSync failed for git root "${raw}", using resolve: ${e.message}`);
42
+ root = path.resolve(raw);
43
+ }
44
+ } catch (e) {
45
+ debug(`git rev-parse failed for "${dir}": ${e.message}`);
46
+ root = null;
47
+ }
48
+ if (!fromDir) {
49
+ _cachedRepoRoot = root;
50
+ _cachedRepoRootCwd = dir;
51
+ }
52
+ return root;
53
+ }
54
+
55
+ /** Reset the cached repo root (for testing). */
56
+ export function _resetRepoRootCache() {
57
+ _cachedRepoRoot = undefined;
58
+ _cachedRepoRootCwd = undefined;
59
+ }
6
60
 
7
61
  function isProcessAlive(pid) {
8
62
  try {
9
63
  process.kill(pid, 0);
10
64
  return true;
11
- } catch {
65
+ } catch (e) {
66
+ debug(`PID ${pid} not alive: ${e.code || e.message}`);
12
67
  return false;
13
68
  }
14
69
  }
@@ -23,13 +78,13 @@ function acquireAdvisoryLock(dbPath) {
23
78
  warn(`Another process (PID ${pid}) may be using this database. Proceeding with caution.`);
24
79
  }
25
80
  }
26
- } catch {
27
- /* ignore read errors */
81
+ } catch (e) {
82
+ debug(`Advisory lock read failed: ${e.message}`);
28
83
  }
29
84
  try {
30
85
  fs.writeFileSync(lockPath, String(process.pid), 'utf-8');
31
- } catch {
32
- /* best-effort */
86
+ } catch (e) {
87
+ debug(`Advisory lock write failed: ${e.message}`);
33
88
  }
34
89
  }
35
90
 
@@ -39,8 +94,25 @@ function releaseAdvisoryLock(lockPath) {
39
94
  if (Number(content) === process.pid) {
40
95
  fs.unlinkSync(lockPath);
41
96
  }
42
- } catch {
43
- /* ignore */
97
+ } catch (e) {
98
+ debug(`Advisory lock release failed for ${lockPath}: ${e.message}`);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Check if two paths refer to the same directory.
104
+ * Handles Windows 8.3 short names (RUNNER~1 vs runneradmin) and macOS
105
+ * symlinks (/tmp vs /private/tmp) where string comparison fails.
106
+ */
107
+ function isSameDirectory(a, b) {
108
+ if (path.resolve(a) === path.resolve(b)) return true;
109
+ try {
110
+ const sa = fs.statSync(a);
111
+ const sb = fs.statSync(b);
112
+ return sa.dev === sb.dev && sa.ino === sb.ino;
113
+ } catch (e) {
114
+ debug(`isSameDirectory stat failed: ${e.message}`);
115
+ return false;
44
116
  }
45
117
  }
46
118
 
@@ -62,15 +134,43 @@ export function closeDb(db) {
62
134
 
63
135
  export function findDbPath(customPath) {
64
136
  if (customPath) return path.resolve(customPath);
65
- let dir = process.cwd();
137
+ const rawCeiling = findRepoRoot();
138
+ // Normalize ceiling with realpathSync to resolve 8.3 short names (Windows
139
+ // RUNNER~1 → runneradmin) and symlinks (macOS /var → /private/var).
140
+ // findRepoRoot already applies realpathSync internally, but the git output
141
+ // may still contain short names on some Windows CI environments.
142
+ let ceiling;
143
+ if (rawCeiling) {
144
+ try {
145
+ ceiling = fs.realpathSync(rawCeiling);
146
+ } catch (e) {
147
+ debug(`realpathSync failed for ceiling "${rawCeiling}": ${e.message}`);
148
+ ceiling = rawCeiling;
149
+ }
150
+ } else {
151
+ ceiling = null;
152
+ }
153
+ // Resolve symlinks (e.g. macOS /var → /private/var) so dir matches ceiling from git
154
+ let dir;
155
+ try {
156
+ dir = fs.realpathSync(process.cwd());
157
+ } catch (e) {
158
+ debug(`realpathSync failed for cwd: ${e.message}`);
159
+ dir = process.cwd();
160
+ }
66
161
  while (true) {
67
162
  const candidate = path.join(dir, '.codegraph', 'graph.db');
68
163
  if (fs.existsSync(candidate)) return candidate;
164
+ if (ceiling && isSameDirectory(dir, ceiling)) {
165
+ debug(`findDbPath: stopped at git ceiling ${ceiling}`);
166
+ break;
167
+ }
69
168
  const parent = path.dirname(dir);
70
169
  if (parent === dir) break;
71
170
  dir = parent;
72
171
  }
73
- return path.join(process.cwd(), '.codegraph', 'graph.db');
172
+ const base = ceiling || process.cwd();
173
+ return path.join(base, '.codegraph', 'graph.db');
74
174
  }
75
175
 
76
176
  /**
@@ -86,3 +186,32 @@ export function openReadonlyOrFail(customPath) {
86
186
  }
87
187
  return new Database(dbPath, { readonly: true });
88
188
  }
189
+
190
+ /**
191
+ * Open a Repository from either an injected instance or a DB path.
192
+ *
193
+ * When `opts.repo` is a Repository instance, returns it directly (no DB opened).
194
+ * Otherwise opens a readonly SQLite DB and wraps it in SqliteRepository.
195
+ *
196
+ * @param {string} [customDbPath] - Path to graph.db (ignored when opts.repo is set)
197
+ * @param {object} [opts]
198
+ * @param {Repository} [opts.repo] - Pre-built Repository to use instead of SQLite
199
+ * @returns {{ repo: Repository, close(): void }}
200
+ */
201
+ export function openRepo(customDbPath, opts = {}) {
202
+ if (opts.repo != null) {
203
+ if (!(opts.repo instanceof Repository)) {
204
+ throw new TypeError(
205
+ `openRepo: opts.repo must be a Repository instance, got ${Object.prototype.toString.call(opts.repo)}`,
206
+ );
207
+ }
208
+ return { repo: opts.repo, close() {} };
209
+ }
210
+ const db = openReadonlyOrFail(customDbPath);
211
+ return {
212
+ repo: new SqliteRepository(db),
213
+ close() {
214
+ db.close();
215
+ },
216
+ };
217
+ }
@@ -1,13 +1,20 @@
1
- // Barrel re-export — keeps all existing `import { ... } from './db.js'` working.
2
- export { closeDb, findDbPath, openDb, openReadonlyOrFail } from './db/connection.js';
3
- export { getBuildMeta, initSchema, MIGRATIONS, setBuildMeta } from './db/migrations.js';
1
+ // Barrel re-export — keeps all existing `import { ... } from '…/db/index.js'` working.
2
+ export {
3
+ closeDb,
4
+ findDbPath,
5
+ findRepoRoot,
6
+ openDb,
7
+ openReadonlyOrFail,
8
+ openRepo,
9
+ } from './connection.js';
10
+ export { getBuildMeta, initSchema, MIGRATIONS, setBuildMeta } from './migrations.js';
4
11
  export {
5
12
  fanInJoinSQL,
6
13
  fanOutJoinSQL,
7
14
  kindInClause,
8
15
  NodeQuery,
9
16
  testFilterSQL,
10
- } from './db/query-builder.js';
17
+ } from './query-builder.js';
11
18
  export {
12
19
  bulkNodeIdsByFile,
13
20
  countCrossFileCallers,
@@ -60,4 +67,4 @@ export {
60
67
  Repository,
61
68
  SqliteRepository,
62
69
  upsertCoChangeMeta,
63
- } from './db/repository/index.js';
70
+ } from './repository/index.js';
@@ -1,4 +1,4 @@
1
- import { debug } from '../logger.js';
1
+ import { debug } from '../infrastructure/logger.js';
2
2
 
3
3
  // ─── Schema Migrations ─────────────────────────────────────────────────
4
4
  export const MIGRATIONS = [
@@ -242,11 +242,23 @@ export const MIGRATIONS = [
242
242
  },
243
243
  ];
244
244
 
245
+ function hasColumn(db, table, column) {
246
+ const cols = db.pragma(`table_info(${table})`);
247
+ return cols.some((c) => c.name === column);
248
+ }
249
+
250
+ function hasTable(db, table) {
251
+ const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table);
252
+ return !!row;
253
+ }
254
+
245
255
  export function getBuildMeta(db, key) {
256
+ if (!hasTable(db, 'build_meta')) return null;
246
257
  try {
247
258
  const row = db.prepare('SELECT value FROM build_meta WHERE key = ?').get(key);
248
259
  return row ? row.value : null;
249
- } catch {
260
+ } catch (e) {
261
+ debug(`getBuildMeta failed for key "${key}": ${e.message}`);
250
262
  return null;
251
263
  }
252
264
  }
@@ -280,74 +292,39 @@ export function initSchema(db) {
280
292
  }
281
293
  }
282
294
 
283
- try {
284
- db.exec('ALTER TABLE nodes ADD COLUMN end_line INTEGER');
285
- } catch {
286
- /* already exists */
287
- }
288
- try {
289
- db.exec('ALTER TABLE edges ADD COLUMN confidence REAL DEFAULT 1.0');
290
- } catch {
291
- /* already exists */
292
- }
293
- try {
294
- db.exec('ALTER TABLE edges ADD COLUMN dynamic INTEGER DEFAULT 0');
295
- } catch {
296
- /* already exists */
297
- }
298
- try {
299
- db.exec('ALTER TABLE nodes ADD COLUMN role TEXT');
300
- } catch {
301
- /* already exists */
302
- }
303
- try {
295
+ // Legacy column compat — add columns that may be missing from pre-migration DBs
296
+ if (hasTable(db, 'nodes')) {
297
+ if (!hasColumn(db, 'nodes', 'end_line')) {
298
+ db.exec('ALTER TABLE nodes ADD COLUMN end_line INTEGER');
299
+ }
300
+ if (!hasColumn(db, 'nodes', 'role')) {
301
+ db.exec('ALTER TABLE nodes ADD COLUMN role TEXT');
302
+ }
304
303
  db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_role ON nodes(role)');
305
- } catch {
306
- /* already exists */
307
- }
308
- try {
309
- db.exec('ALTER TABLE nodes ADD COLUMN parent_id INTEGER REFERENCES nodes(id)');
310
- } catch {
311
- /* already exists */
312
- }
313
- try {
304
+ if (!hasColumn(db, 'nodes', 'parent_id')) {
305
+ db.exec('ALTER TABLE nodes ADD COLUMN parent_id INTEGER REFERENCES nodes(id)');
306
+ }
314
307
  db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_id)');
315
- } catch {
316
- /* already exists */
317
- }
318
- try {
319
308
  db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_kind_parent ON nodes(kind, parent_id)');
320
- } catch {
321
- /* already exists */
322
- }
323
- try {
324
- db.exec('ALTER TABLE nodes ADD COLUMN qualified_name TEXT');
325
- } catch {
326
- /* already exists */
327
- }
328
- try {
329
- db.exec('ALTER TABLE nodes ADD COLUMN scope TEXT');
330
- } catch {
331
- /* already exists */
332
- }
333
- try {
334
- db.exec('ALTER TABLE nodes ADD COLUMN visibility TEXT');
335
- } catch {
336
- /* already exists */
337
- }
338
- try {
309
+ if (!hasColumn(db, 'nodes', 'qualified_name')) {
310
+ db.exec('ALTER TABLE nodes ADD COLUMN qualified_name TEXT');
311
+ }
312
+ if (!hasColumn(db, 'nodes', 'scope')) {
313
+ db.exec('ALTER TABLE nodes ADD COLUMN scope TEXT');
314
+ }
315
+ if (!hasColumn(db, 'nodes', 'visibility')) {
316
+ db.exec('ALTER TABLE nodes ADD COLUMN visibility TEXT');
317
+ }
339
318
  db.exec('UPDATE nodes SET qualified_name = name WHERE qualified_name IS NULL');
340
- } catch {
341
- /* nodes table may not exist yet */
342
- }
343
- try {
344
319
  db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_qualified_name ON nodes(qualified_name)');
345
- } catch {
346
- /* already exists */
347
- }
348
- try {
349
320
  db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_scope ON nodes(scope)');
350
- } catch {
351
- /* already exists */
321
+ }
322
+ if (hasTable(db, 'edges')) {
323
+ if (!hasColumn(db, 'edges', 'confidence')) {
324
+ db.exec('ALTER TABLE edges ADD COLUMN confidence REAL DEFAULT 1.0');
325
+ }
326
+ if (!hasColumn(db, 'edges', 'dynamic')) {
327
+ db.exec('ALTER TABLE edges ADD COLUMN dynamic INTEGER DEFAULT 0');
328
+ }
352
329
  }
353
330
  }
@@ -1,5 +1,5 @@
1
- import { DbError } from '../errors.js';
2
- import { EVERY_EDGE_KIND } from '../kinds.js';
1
+ import { DbError } from '../shared/errors.js';
2
+ import { EVERY_EDGE_KIND } from '../shared/kinds.js';
3
3
 
4
4
  // ─── Validation Helpers ─────────────────────────────────────────────
5
5
 
@@ -65,6 +65,62 @@ function validateEdgeKind(edgeKind) {
65
65
  }
66
66
  }
67
67
 
68
+ // ─── LIKE Escaping ──────────────────────────────────────────────────
69
+
70
+ /** Escape LIKE wildcards in a literal string segment. */
71
+ export function escapeLike(s) {
72
+ return s.replace(/[%_\\]/g, '\\$&');
73
+ }
74
+
75
+ /**
76
+ * Normalize a file filter value (string, string[], or falsy) into a flat array.
77
+ * Returns an empty array when the input is falsy.
78
+ * @param {string|string[]|undefined|null} file
79
+ * @returns {string[]}
80
+ */
81
+ export function normalizeFileFilter(file) {
82
+ if (!file) return [];
83
+ return Array.isArray(file) ? file : [file];
84
+ }
85
+
86
+ /**
87
+ * Build a SQL condition + params for a multi-value file LIKE filter.
88
+ * Returns `{ sql: '', params: [] }` when the filter is empty.
89
+ *
90
+ * @param {string|string[]} file - One or more partial file paths
91
+ * @param {string} [column='file'] - The column name to filter on (e.g. 'n.file', 'a.file')
92
+ * @returns {{ sql: string, params: string[] }}
93
+ */
94
+ export function buildFileConditionSQL(file, column = 'file') {
95
+ validateColumn(column);
96
+ const files = normalizeFileFilter(file);
97
+ if (files.length === 0) return { sql: '', params: [] };
98
+ if (files.length === 1) {
99
+ return {
100
+ sql: ` AND ${column} LIKE ? ESCAPE '\\'`,
101
+ params: [`%${escapeLike(files[0])}%`],
102
+ };
103
+ }
104
+ const clauses = files.map(() => `${column} LIKE ? ESCAPE '\\'`);
105
+ return {
106
+ sql: ` AND (${clauses.join(' OR ')})`,
107
+ params: files.map((f) => `%${escapeLike(f)}%`),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Commander option accumulator for repeatable `--file` flag.
113
+ * Use as: `['-f, --file <path>', 'Scope to file (partial match, repeatable)', collectFile]`
114
+ * @param {string} val - New value from Commander
115
+ * @param {string[]} acc - Accumulated values (undefined on first call)
116
+ * @returns {string[]}
117
+ */
118
+ export function collectFile(val, acc) {
119
+ acc = acc || [];
120
+ acc.push(val);
121
+ return acc;
122
+ }
123
+
68
124
  // ─── Standalone Helpers ──────────────────────────────────────────────
69
125
 
70
126
  /**
@@ -164,11 +220,18 @@ export class NodeQuery {
164
220
  return this;
165
221
  }
166
222
 
167
- /** WHERE n.file LIKE ? (no-op if falsy). */
223
+ /** WHERE n.file LIKE ? (no-op if falsy). Accepts a single string or string[]. */
168
224
  fileFilter(file) {
169
- if (!file) return this;
170
- this.#conditions.push('n.file LIKE ?');
171
- this.#params.push(`%${file}%`);
225
+ const files = normalizeFileFilter(file);
226
+ if (files.length === 0) return this;
227
+ if (files.length === 1) {
228
+ this.#conditions.push("n.file LIKE ? ESCAPE '\\'");
229
+ this.#params.push(`%${escapeLike(files[0])}%`);
230
+ } else {
231
+ const clauses = files.map(() => "n.file LIKE ? ESCAPE '\\'");
232
+ this.#conditions.push(`(${clauses.join(' OR ')})`);
233
+ this.#params.push(...files.map((f) => `%${escapeLike(f)}%`));
234
+ }
172
235
  return this;
173
236
  }
174
237
 
@@ -188,11 +251,11 @@ export class NodeQuery {
188
251
  return this;
189
252
  }
190
253
 
191
- /** WHERE n.name LIKE ? (no-op if falsy). */
254
+ /** WHERE n.name LIKE ? (no-op if falsy). Escapes LIKE wildcards in the value. */
192
255
  nameLike(pattern) {
193
256
  if (!pattern) return this;
194
- this.#conditions.push('n.name LIKE ?');
195
- this.#params.push(`%${pattern}%`);
257
+ this.#conditions.push("n.name LIKE ? ESCAPE '\\'");
258
+ this.#params.push(`%${escapeLike(pattern)}%`);
196
259
  return this;
197
260
  }
198
261
 
@@ -163,7 +163,7 @@ export class Repository {
163
163
  throw new Error('not implemented');
164
164
  }
165
165
 
166
- /** @returns {{ source_id: number, target_id: number }[]} */
166
+ /** @returns {{ source_id: number, target_id: number, confidence: number|null }[]} */
167
167
  getCallEdges() {
168
168
  throw new Error('not implemented');
169
169
  }
@@ -1,4 +1,4 @@
1
- import { CORE_SYMBOL_KINDS } from '../../kinds.js';
1
+ import { CORE_SYMBOL_KINDS } from '../../shared/kinds.js';
2
2
  import { cachedStmt } from './cached-stmt.js';
3
3
 
4
4
  // ─── Statement caches (one prepared statement per db instance) ────────────
@@ -25,13 +25,13 @@ export function getCallableNodes(db) {
25
25
  /**
26
26
  * Get all 'calls' edges.
27
27
  * @param {object} db
28
- * @returns {{ source_id: number, target_id: number }[]}
28
+ * @returns {{ source_id: number, target_id: number, confidence: number|null }[]}
29
29
  */
30
30
  export function getCallEdges(db) {
31
31
  return cachedStmt(
32
32
  _getCallEdgesStmt,
33
33
  db,
34
- "SELECT source_id, target_id FROM edges WHERE kind = 'calls'",
34
+ "SELECT source_id, target_id, confidence FROM edges WHERE kind = 'calls'",
35
35
  ).all();
36
36
  }
37
37
 
@@ -1,17 +1,8 @@
1
- import { ConfigError } from '../../errors.js';
2
- import { CORE_SYMBOL_KINDS, EVERY_SYMBOL_KIND, VALID_ROLES } from '../../kinds.js';
1
+ import { ConfigError } from '../../shared/errors.js';
2
+ import { CORE_SYMBOL_KINDS, EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js';
3
+ import { escapeLike, normalizeFileFilter } from '../query-builder.js';
3
4
  import { Repository } from './base.js';
4
5
 
5
- /**
6
- * Escape LIKE special characters so they are treated as literals.
7
- * Mirrors the `escapeLike` function in `nodes.js`.
8
- * @param {string} s
9
- * @returns {string}
10
- */
11
- function escapeLike(s) {
12
- return s.replace(/[%_\\]/g, '\\$&');
13
- }
14
-
15
6
  /**
16
7
  * Convert a SQL LIKE pattern to a RegExp (case-insensitive).
17
8
  * Supports `%` (any chars) and `_` (single char).
@@ -36,6 +27,17 @@ function likeToRegex(pattern) {
36
27
  return new RegExp(`^${regex}$`, 'i');
37
28
  }
38
29
 
30
+ /**
31
+ * Build a filter predicate for file matching.
32
+ * Accepts string, string[], or falsy. Returns null when no filtering needed.
33
+ */
34
+ function buildFileFilterFn(file) {
35
+ const files = normalizeFileFilter(file);
36
+ if (files.length === 0) return null;
37
+ const regexes = files.map((f) => likeToRegex(`%${escapeLike(f)}%`));
38
+ return (filePath) => regexes.some((re) => re.test(filePath));
39
+ }
40
+
39
41
  /**
40
42
  * In-memory Repository implementation backed by Maps.
41
43
  * No SQLite dependency — suitable for fast unit tests.
@@ -130,9 +132,9 @@ export class InMemoryRepository extends Repository {
130
132
  if (opts.kinds) {
131
133
  nodes = nodes.filter((n) => opts.kinds.includes(n.kind));
132
134
  }
133
- if (opts.file) {
134
- const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`);
135
- nodes = nodes.filter((n) => fileRe.test(n.file));
135
+ {
136
+ const fileFn = buildFileFilterFn(opts.file);
137
+ if (fileFn) nodes = nodes.filter((n) => fileFn(n.file));
136
138
  }
137
139
 
138
140
  // Compute fan-in per node
@@ -206,9 +208,9 @@ export class InMemoryRepository extends Repository {
206
208
  if (opts.kind) {
207
209
  nodes = nodes.filter((n) => n.kind === opts.kind);
208
210
  }
209
- if (opts.file) {
210
- const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`);
211
- nodes = nodes.filter((n) => fileRe.test(n.file));
211
+ {
212
+ const fileFn = buildFileFilterFn(opts.file);
213
+ if (fileFn) nodes = nodes.filter((n) => fileFn(n.file));
212
214
  }
213
215
 
214
216
  return nodes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
@@ -217,9 +219,9 @@ export class InMemoryRepository extends Repository {
217
219
  findNodeByQualifiedName(qualifiedName, opts = {}) {
218
220
  let nodes = [...this.#nodes.values()].filter((n) => n.qualified_name === qualifiedName);
219
221
 
220
- if (opts.file) {
221
- const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`);
222
- nodes = nodes.filter((n) => fileRe.test(n.file));
222
+ {
223
+ const fileFn = buildFileFilterFn(opts.file);
224
+ if (fileFn) nodes = nodes.filter((n) => fileFn(n.file));
223
225
  }
224
226
 
225
227
  return nodes.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
@@ -257,9 +259,9 @@ export class InMemoryRepository extends Repository {
257
259
  !n.file.includes('.stories.'),
258
260
  );
259
261
  }
260
- if (opts.file) {
261
- const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`);
262
- nodes = nodes.filter((n) => fileRe.test(n.file));
262
+ {
263
+ const fileFn = buildFileFilterFn(opts.file);
264
+ if (fileFn) nodes = nodes.filter((n) => fileFn(n.file));
263
265
  }
264
266
  if (opts.role) {
265
267
  nodes = nodes.filter((n) => n.role === opts.role);
@@ -498,7 +500,7 @@ export class InMemoryRepository extends Repository {
498
500
  getCallEdges() {
499
501
  return [...this.#edges.values()]
500
502
  .filter((e) => e.kind === 'calls')
501
- .map((e) => ({ source_id: e.source_id, target_id: e.target_id }));
503
+ .map((e) => ({ source_id: e.source_id, target_id: e.target_id, confidence: e.confidence }));
502
504
  }
503
505
 
504
506
  getFileNodesAll() {
@@ -550,9 +552,9 @@ export class InMemoryRepository extends Repository {
550
552
  ['function', 'method', 'class'].includes(n.kind),
551
553
  );
552
554
 
553
- if (opts.file) {
554
- const fileRe = likeToRegex(`%${escapeLike(opts.file)}%`);
555
- nodes = nodes.filter((n) => fileRe.test(n.file));
555
+ {
556
+ const fileFn = buildFileFilterFn(opts.file);
557
+ if (fileFn) nodes = nodes.filter((n) => fileFn(n.file));
556
558
  }
557
559
  if (opts.pattern) {
558
560
  const patternRe = likeToRegex(`%${escapeLike(opts.pattern)}%`);
@@ -1,6 +1,6 @@
1
- import { ConfigError } from '../../errors.js';
2
- import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../kinds.js';
3
- import { NodeQuery } from '../query-builder.js';
1
+ import { ConfigError } from '../../shared/errors.js';
2
+ import { EVERY_SYMBOL_KIND, VALID_ROLES } from '../../shared/kinds.js';
3
+ import { buildFileConditionSQL, NodeQuery } from '../query-builder.js';
4
4
  import { cachedStmt } from './cached-stmt.js';
5
5
 
6
6
  // ─── Query-builder based lookups (moved from src/db/repository.js) ─────
@@ -250,11 +250,6 @@ export function findNodeChildren(db, parentId) {
250
250
  ).all(parentId);
251
251
  }
252
252
 
253
- /** Escape LIKE wildcards in a literal string segment. */
254
- function escapeLike(s) {
255
- return s.replace(/[%_\\]/g, '\\$&');
256
- }
257
-
258
253
  /**
259
254
  * Find all nodes that belong to a given scope (by scope column).
260
255
  * Enables "all methods of class X" without traversing edges.
@@ -272,10 +267,9 @@ export function findNodesByScope(db, scopeName, opts = {}) {
272
267
  sql += ' AND kind = ?';
273
268
  params.push(opts.kind);
274
269
  }
275
- if (opts.file) {
276
- sql += " AND file LIKE ? ESCAPE '\\'";
277
- params.push(`%${escapeLike(opts.file)}%`);
278
- }
270
+ const fc = buildFileConditionSQL(opts.file, 'file');
271
+ sql += fc.sql;
272
+ params.push(...fc.params);
279
273
  sql += ' ORDER BY file, line';
280
274
  return db.prepare(sql).all(...params);
281
275
  }
@@ -291,12 +285,11 @@ export function findNodesByScope(db, scopeName, opts = {}) {
291
285
  * @returns {object[]}
292
286
  */
293
287
  export function findNodeByQualifiedName(db, qualifiedName, opts = {}) {
294
- if (opts.file) {
288
+ const fc = buildFileConditionSQL(opts.file, 'file');
289
+ if (fc.sql) {
295
290
  return db
296
- .prepare(
297
- "SELECT * FROM nodes WHERE qualified_name = ? AND file LIKE ? ESCAPE '\\' ORDER BY file, line",
298
- )
299
- .all(qualifiedName, `%${escapeLike(opts.file)}%`);
291
+ .prepare(`SELECT * FROM nodes WHERE qualified_name = ?${fc.sql} ORDER BY file, line`)
292
+ .all(qualifiedName, ...fc.params);
300
293
  }
301
294
  return cachedStmt(
302
295
  _findNodeByQualifiedNameStmt,