milens 0.7.1 → 0.7.3

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 (119) hide show
  1. package/.agents/skills/adapters/SKILL.md +59 -54
  2. package/.agents/skills/analyzer/SKILL.md +83 -78
  3. package/.agents/skills/apps/SKILL.md +65 -60
  4. package/.agents/skills/docs/SKILL.md +63 -58
  5. package/.agents/skills/milens/SKILL.md +322 -319
  6. package/.agents/skills/milens-security-review/SKILL.md +224 -224
  7. package/.agents/skills/orchestrator/SKILL.md +64 -59
  8. package/.agents/skills/parser/SKILL.md +86 -81
  9. package/.agents/skills/root/SKILL.md +89 -84
  10. package/.agents/skills/scripts/SKILL.md +56 -51
  11. package/.agents/skills/security/SKILL.md +67 -62
  12. package/.agents/skills/server/SKILL.md +84 -77
  13. package/.agents/skills/store/SKILL.md +77 -72
  14. package/.agents/skills/test/SKILL.md +155 -148
  15. package/.agents/skills/ui/SKILL.md +56 -51
  16. package/README.md +577 -577
  17. package/adapters/README.md +144 -144
  18. package/adapters/claude-code/.claude/mcp.json +9 -9
  19. package/adapters/claude-code/.claude/settings.json.hooks-snippet.json +26 -26
  20. package/adapters/claude-code/.claude-plugin/plugin.json +11 -11
  21. package/adapters/claude-code/.mcp.json +9 -9
  22. package/adapters/claude-code/CLAUDE.md +81 -79
  23. package/adapters/claude-code/hooks/hooks.json +27 -0
  24. package/adapters/codex/.codex/config.toml +3 -3
  25. package/adapters/copilot/.vscode/mcp.json +10 -10
  26. package/adapters/cursor/.cursor/mcp.json +9 -9
  27. package/adapters/cursor/.cursorrules +69 -69
  28. package/adapters/gemini/.gemini/context.md +81 -81
  29. package/adapters/gemini/.gemini/settings.json +9 -9
  30. package/adapters/opencode/.opencode/config.json +8 -8
  31. package/adapters/zed/.zed/settings.json +8 -8
  32. package/dist/agents-md.d.ts +1 -0
  33. package/dist/agents-md.js +45 -1
  34. package/dist/agents-md.js.map +1 -1
  35. package/dist/analyzer/config.js +32 -0
  36. package/dist/analyzer/config.js.map +1 -1
  37. package/dist/analyzer/engine.js +26 -2
  38. package/dist/analyzer/engine.js.map +1 -1
  39. package/dist/analyzer/enrich.js +68 -1
  40. package/dist/analyzer/enrich.js.map +1 -1
  41. package/dist/analyzer/resolver.d.ts +4 -1
  42. package/dist/analyzer/resolver.js +241 -38
  43. package/dist/analyzer/resolver.js.map +1 -1
  44. package/dist/analyzer/review.js +32 -2
  45. package/dist/analyzer/review.js.map +1 -1
  46. package/dist/analyzer/scanner.js +3 -0
  47. package/dist/analyzer/scanner.js.map +1 -1
  48. package/dist/analyzer/scope-resolver.js +1 -1
  49. package/dist/analyzer/scope-resolver.js.map +1 -1
  50. package/dist/build-info.d.ts +2 -2
  51. package/dist/build-info.js +2 -2
  52. package/dist/cli.js +566 -497
  53. package/dist/cli.js.map +1 -1
  54. package/dist/orchestrator/orchestrator.d.ts +1 -1
  55. package/dist/orchestrator/orchestrator.js +14 -6
  56. package/dist/orchestrator/orchestrator.js.map +1 -1
  57. package/dist/parser/extract.d.ts +7 -0
  58. package/dist/parser/extract.js +23 -1
  59. package/dist/parser/extract.js.map +1 -1
  60. package/dist/parser/lang-css.js +18 -6
  61. package/dist/parser/lang-css.js.map +1 -1
  62. package/dist/parser/lang-go.js +58 -58
  63. package/dist/parser/lang-html.js +3 -3
  64. package/dist/parser/lang-java.js +37 -37
  65. package/dist/parser/lang-js.js +120 -109
  66. package/dist/parser/lang-js.js.map +1 -1
  67. package/dist/parser/lang-md.js +1 -0
  68. package/dist/parser/lang-md.js.map +1 -1
  69. package/dist/parser/lang-php.js +45 -45
  70. package/dist/parser/lang-py.js +43 -43
  71. package/dist/parser/lang-ruby.js +28 -28
  72. package/dist/parser/lang-rust.js +48 -48
  73. package/dist/parser/lang-ts.js +230 -196
  74. package/dist/parser/lang-ts.js.map +1 -1
  75. package/dist/parser/lang-vue.js +0 -21
  76. package/dist/parser/lang-vue.js.map +1 -1
  77. package/dist/security/rules.js +22 -23
  78. package/dist/security/rules.js.map +1 -1
  79. package/dist/server/guard-hook.d.ts +10 -0
  80. package/dist/server/guard-hook.js +179 -0
  81. package/dist/server/guard-hook.js.map +1 -0
  82. package/dist/server/hooks.js +2 -1
  83. package/dist/server/hooks.js.map +1 -1
  84. package/dist/server/mcp-prompts.js +502 -502
  85. package/dist/server/mcp.js +228 -86
  86. package/dist/server/mcp.js.map +1 -1
  87. package/dist/server/test-plan.d.ts +1 -0
  88. package/dist/server/test-plan.js +25 -7
  89. package/dist/server/test-plan.js.map +1 -1
  90. package/dist/server/tools/findings-report.d.ts +3 -0
  91. package/dist/server/tools/findings-report.js +170 -0
  92. package/dist/server/tools/findings-report.js.map +1 -0
  93. package/dist/server/tools/security.js +61 -5
  94. package/dist/server/tools/security.js.map +1 -1
  95. package/dist/server/tools/session.js +37 -17
  96. package/dist/server/tools/session.js.map +1 -1
  97. package/dist/server/tools/testing.js +58 -11
  98. package/dist/server/tools/testing.js.map +1 -1
  99. package/dist/skills.js +307 -301
  100. package/dist/skills.js.map +1 -1
  101. package/dist/store/annotations.d.ts +5 -0
  102. package/dist/store/annotations.js +45 -9
  103. package/dist/store/annotations.js.map +1 -1
  104. package/dist/store/db.d.ts +13 -0
  105. package/dist/store/db.js +318 -205
  106. package/dist/store/db.js.map +1 -1
  107. package/dist/store/registry.d.ts +1 -0
  108. package/dist/store/registry.js +17 -6
  109. package/dist/store/registry.js.map +1 -1
  110. package/dist/store/schema.sql +144 -143
  111. package/dist/store/vectors.js +2 -2
  112. package/dist/types.d.ts +16 -1
  113. package/dist/uninstall.d.ts +1 -0
  114. package/dist/uninstall.js +33 -10
  115. package/dist/uninstall.js.map +1 -1
  116. package/dist/utils.d.ts +1 -1
  117. package/dist/utils.js +0 -0
  118. package/dist/utils.js.map +1 -1
  119. package/package.json +85 -85
package/dist/store/db.js CHANGED
@@ -34,43 +34,43 @@ export class Database {
34
34
  prepareStatements() {
35
35
  return {
36
36
  checkHash: this.db.prepare('SELECT hash FROM file_hashes WHERE path = ?'),
37
- upsertHash: this.db.prepare(`INSERT INTO file_hashes (path, hash) VALUES (?, ?)
37
+ upsertHash: this.db.prepare(`INSERT INTO file_hashes (path, hash) VALUES (?, ?)
38
38
  ON CONFLICT(path) DO UPDATE SET hash = excluded.hash, analyzed_at = datetime('now')`),
39
- insertSym: this.db.prepare(`INSERT OR REPLACE INTO symbols (id, name, kind, file_path, start_line, end_line, exported, parent_id, signature, role, heat)
39
+ insertSym: this.db.prepare(`INSERT OR REPLACE INTO symbols (id, name, kind, file_path, start_line, end_line, exported, parent_id, signature, role, heat)
40
40
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
41
41
  updateMeta: this.db.prepare(`UPDATE symbols SET role = ?, heat = ? WHERE id = ?`),
42
42
  upsertZone: this.db.prepare(`UPDATE file_hashes SET zone = ? WHERE path = ?`),
43
- insertLink: this.db.prepare(`INSERT OR REPLACE INTO links (id, from_id, to_id, type, confidence, line_number)
43
+ insertLink: this.db.prepare(`INSERT OR REPLACE INTO links (id, from_id, to_id, type, confidence, line_number)
44
44
  VALUES (?, ?, ?, ?, ?, ?)`),
45
- searchFts: this.db.prepare(`SELECT s.* FROM symbol_fts f
46
- JOIN symbols s ON s.rowid = f.rowid
47
- WHERE symbol_fts MATCH ?
45
+ searchFts: this.db.prepare(`SELECT s.* FROM symbol_fts f
46
+ JOIN symbols s ON s.rowid = f.rowid
47
+ WHERE symbol_fts MATCH ?
48
48
  ORDER BY rank LIMIT ?`),
49
49
  byName: this.db.prepare('SELECT * FROM symbols WHERE name = ?'),
50
50
  byId: this.db.prepare('SELECT * FROM symbols WHERE id = ?'),
51
51
  byFile: this.db.prepare('SELECT * FROM symbols WHERE file_path = ?'),
52
52
  linksIn: this.db.prepare('SELECT * FROM links WHERE to_id = ?'),
53
53
  linksOut: this.db.prepare('SELECT * FROM links WHERE from_id = ?'),
54
- upstream: this.db.prepare(`
55
- WITH RECURSIVE upstream(id, depth, via) AS (
56
- SELECT from_id, 1, type FROM links WHERE to_id = ? AND type IN ('calls', 'imports', 'extends', 'implements')
57
- UNION
58
- SELECT l.from_id, u.depth + 1, l.type
59
- FROM links l JOIN upstream u ON l.to_id = u.id
60
- WHERE u.depth < ? AND l.type IN ('calls', 'imports', 'extends', 'implements')
61
- )
62
- SELECT DISTINCT s.*, u.depth, u.via FROM upstream u JOIN symbols s ON s.id = u.id ORDER BY u.depth
54
+ upstream: this.db.prepare(`
55
+ WITH RECURSIVE upstream(id, depth, via) AS (
56
+ SELECT from_id, 1, type FROM links WHERE to_id = ? AND type IN ('calls', 'imports', 'extends', 'implements')
57
+ UNION
58
+ SELECT l.from_id, u.depth + 1, l.type
59
+ FROM links l JOIN upstream u ON l.to_id = u.id
60
+ WHERE u.depth < ? AND l.type IN ('calls', 'imports', 'extends', 'implements')
61
+ )
62
+ SELECT DISTINCT s.*, u.depth, u.via FROM upstream u JOIN symbols s ON s.id = u.id ORDER BY u.depth
63
63
  `),
64
- downstream: this.db.prepare(`
65
- WITH RECURSIVE downstream(id, depth, via, path) AS (
66
- SELECT to_id, 1, type, ',' || to_id || ',' FROM links WHERE from_id = ? AND type IN ('calls', 'imports', 'extends', 'implements')
67
- UNION
68
- SELECT l.to_id, d.depth + 1, l.type, d.path || l.to_id || ','
69
- FROM links l JOIN downstream d ON l.from_id = d.id
70
- WHERE d.depth < ? AND l.type IN ('calls', 'imports', 'extends', 'implements')
71
- AND d.path NOT LIKE '%,' || l.to_id || ',%'
72
- )
73
- SELECT DISTINCT s.*, d.depth, d.via FROM downstream d JOIN symbols s ON s.id = d.id ORDER BY d.depth
64
+ downstream: this.db.prepare(`
65
+ WITH RECURSIVE downstream(id, depth, via, path) AS (
66
+ SELECT to_id, 1, type, ',' || to_id || ',' FROM links WHERE from_id = ? AND type IN ('calls', 'imports', 'extends', 'implements')
67
+ UNION
68
+ SELECT l.to_id, d.depth + 1, l.type, d.path || l.to_id || ','
69
+ FROM links l JOIN downstream d ON l.from_id = d.id
70
+ WHERE d.depth < ? AND l.type IN ('calls', 'imports', 'extends', 'implements')
71
+ AND d.path NOT LIKE '%,' || l.to_id || ',%'
72
+ )
73
+ SELECT DISTINCT s.*, d.depth, d.via FROM downstream d JOIN symbols s ON s.id = d.id ORDER BY d.depth
74
74
  `),
75
75
  countSymbols: this.db.prepare('SELECT COUNT(*) as c FROM symbols'),
76
76
  countLinks: this.db.prepare('SELECT COUNT(*) as c FROM links'),
@@ -78,17 +78,17 @@ export class Database {
78
78
  deleteFileLinks: this.db.prepare('DELETE FROM links WHERE from_id IN (SELECT id FROM symbols WHERE file_path = ?)'),
79
79
  deleteFileSymbols: this.db.prepare('DELETE FROM symbols WHERE file_path = ?'),
80
80
  topHubs: this.db.prepare('SELECT * FROM symbols WHERE exported = 1 ORDER BY heat DESC LIMIT ?'),
81
- testCoverageGaps: this.db.prepare(`
82
- SELECT s.* FROM symbols s
83
- WHERE s.exported = 1 AND s.heat > 0
84
- AND s.id NOT IN (
85
- SELECT DISTINCT l.to_id FROM links l
86
- JOIN symbols src ON src.id = l.from_id
87
- WHERE (src.file_path LIKE '%/test/%' OR src.file_path LIKE '%\\test\\%'
88
- OR src.file_path LIKE '%.test.%' OR src.file_path LIKE '%.spec.%')
89
- )
90
- ORDER BY s.heat DESC
91
- LIMIT ?
81
+ testCoverageGaps: this.db.prepare(`
82
+ SELECT s.* FROM symbols s
83
+ WHERE s.exported = 1 AND s.heat > 0
84
+ AND s.id NOT IN (
85
+ SELECT DISTINCT l.to_id FROM links l
86
+ JOIN symbols src ON src.id = l.from_id
87
+ WHERE (src.file_path LIKE '%/test/%' OR src.file_path LIKE '%\\test\\%'
88
+ OR src.file_path LIKE '%.test.%' OR src.file_path LIKE '%.spec.%')
89
+ )
90
+ ORDER BY s.heat DESC
91
+ LIMIT ?
92
92
  `),
93
93
  annotationCount: this.db.prepare('SELECT COUNT(*) as c FROM annotations'),
94
94
  };
@@ -131,6 +131,9 @@ export class Database {
131
131
  if (!annNames.has('updated_at')) {
132
132
  this.db.exec(`ALTER TABLE annotations ADD COLUMN updated_at TEXT NOT NULL DEFAULT (datetime('now'))`);
133
133
  }
134
+ if (!annNames.has('symbol_hash')) {
135
+ this.db.exec(`ALTER TABLE annotations ADD COLUMN symbol_hash TEXT`);
136
+ }
134
137
  // Rebuild index if column was renamed
135
138
  if (annNames.has('symbol_id')) {
136
139
  this.db.exec(`DROP INDEX IF EXISTS idx_annotations_symbol`);
@@ -258,20 +261,49 @@ export class Database {
258
261
  return rows.map(rowToLink);
259
262
  }
260
263
  findDeadCode(kind, limit = 50) {
261
- const frameworkExclude = `AND s.file_path NOT LIKE 'app/%/page.%' AND s.file_path NOT LIKE 'app/%/layout.%'
262
- AND s.file_path NOT LIKE 'app/page.%' AND s.file_path NOT LIKE 'app/layout.%'
263
- AND s.file_path NOT LIKE 'app/api/%/route.%' AND s.file_path NOT LIKE 'jest.config.%'
264
- AND s.file_path NOT LIKE 'src/routes/+page.%' AND s.file_path NOT LIKE 'src/routes/+layout.%'`;
264
+ // Next.js/SvelteKit/api conventions are matched both at the repo root AND nested
265
+ // under a package subdirectory (e.g. monorepos with frontend/app/... instead of
266
+ // app/...) a root-only 'app/%' pattern silently misses every monorepo layout.
267
+ const frameworkExclude = `AND s.file_path NOT LIKE 'app/%/page.%' AND s.file_path NOT LIKE '%/app/%/page.%'
268
+ AND s.file_path NOT LIKE 'app/%/layout.%' AND s.file_path NOT LIKE '%/app/%/layout.%'
269
+ AND s.file_path NOT LIKE 'app/page.%' AND s.file_path NOT LIKE '%/app/page.%'
270
+ AND s.file_path NOT LIKE 'app/layout.%' AND s.file_path NOT LIKE '%/app/layout.%'
271
+ AND s.file_path NOT LIKE 'app/%/loading.%' AND s.file_path NOT LIKE '%/app/%/loading.%'
272
+ AND s.file_path NOT LIKE 'app/%/error.%' AND s.file_path NOT LIKE '%/app/%/error.%'
273
+ AND s.file_path NOT LIKE 'app/%/not-found.%' AND s.file_path NOT LIKE '%/app/%/not-found.%'
274
+ AND s.file_path NOT LIKE 'app/%/template.%' AND s.file_path NOT LIKE '%/app/%/template.%'
275
+ AND s.file_path NOT LIKE 'app/%/global-error.%' AND s.file_path NOT LIKE '%/app/%/global-error.%'
276
+ AND s.file_path NOT LIKE 'app/%/default.%' AND s.file_path NOT LIKE '%/app/%/default.%'
277
+ AND s.file_path NOT LIKE 'app/api/%/route.%' AND s.file_path NOT LIKE '%/app/api/%/route.%'
278
+ AND s.file_path NOT LIKE 'app/%/route.%' AND s.file_path NOT LIKE '%/app/%/route.%'
279
+ AND s.file_path NOT LIKE 'jest.config.%'
280
+ AND s.file_path NOT LIKE 'src/routes/+page.%' AND s.file_path NOT LIKE '%/src/routes/+page.%'
281
+ AND s.file_path NOT LIKE 'src/routes/+layout.%' AND s.file_path NOT LIKE '%/src/routes/+layout.%'
282
+ AND s.file_path NOT LIKE '%/alembic/versions/%'
283
+ AND s.file_path NOT LIKE '%/migrations/%'
284
+ AND s.file_path NOT LIKE 'api/%' AND s.file_path NOT LIKE '%/api/%'
285
+ AND NOT ((s.file_path LIKE 'app/%' OR s.file_path LIKE '%/app/%') AND s.name IN ('generateStaticParams', 'metadata', 'generateMetadata', 'viewport', 'generateViewport', 'revalidate', 'dynamic', 'fetchCache', 'runtime', 'preferredRegion', 'maxDuration'))`;
286
+ // Vue SFC root components imported via <Component/> template tags get their
287
+ // import link on _top [module], not on the [class] root symbol. Treat the class
288
+ // as referenced if its file's _top module has incoming links from other files.
289
+ const vueRootGuard = `AND NOT (s.file_path LIKE '%.vue' AND s.kind = 'class' AND EXISTS (
290
+ SELECT 1 FROM symbols s2
291
+ JOIN links l2 ON l2.to_id = s2.id AND l2.type != 'contains'
292
+ WHERE s2.file_path = s.file_path AND s2.kind = 'module' AND s2.name = '_top'
293
+ AND EXISTS (SELECT 1 FROM links l3 WHERE l3.to_id = s2.id AND l3.type = 'imports')
294
+ ))`;
265
295
  const sql = kind
266
- ? `SELECT s.* FROM symbols s
267
- LEFT JOIN links l ON l.to_id = s.id AND l.type != 'contains'
268
- WHERE s.exported = 1 AND s.kind = ? AND s.kind != 'section' AND l.id IS NULL
269
- ${frameworkExclude}
296
+ ? `SELECT s.* FROM symbols s
297
+ LEFT JOIN links l ON l.to_id = s.id AND l.type != 'contains'
298
+ WHERE s.exported = 1 AND s.kind = ? AND s.kind != 'section' AND l.id IS NULL
299
+ ${frameworkExclude}
300
+ ${vueRootGuard}
270
301
  LIMIT ?`
271
- : `SELECT s.* FROM symbols s
272
- LEFT JOIN links l ON l.to_id = s.id AND l.type != 'contains'
273
- WHERE s.exported = 1 AND s.kind != 'section' AND l.id IS NULL
274
- ${frameworkExclude}
302
+ : `SELECT s.* FROM symbols s
303
+ LEFT JOIN links l ON l.to_id = s.id AND l.type != 'contains'
304
+ WHERE s.exported = 1 AND s.kind != 'section' AND l.id IS NULL
305
+ ${frameworkExclude}
306
+ ${vueRootGuard}
275
307
  LIMIT ?`;
276
308
  const rows = kind
277
309
  ? this.db.prepare(sql).all(kind, limit)
@@ -285,20 +317,38 @@ export class Database {
285
317
  * findDeadCode (which requires zero incoming links of any kind).
286
318
  */
287
319
  findTestOnlyReferenced(limit = 50) {
288
- const frameworkExclude = `AND s.file_path NOT LIKE 'app/%/page.%' AND s.file_path NOT LIKE 'app/%/layout.%'
289
- AND s.file_path NOT LIKE 'app/page.%' AND s.file_path NOT LIKE 'app/layout.%'
290
- AND s.file_path NOT LIKE 'app/api/%/route.%' AND s.file_path NOT LIKE 'jest.config.%'
291
- AND s.file_path NOT LIKE 'src/routes/+page.%' AND s.file_path NOT LIKE 'src/routes/+layout.%'`;
320
+ // Next.js/SvelteKit/api conventions are matched both at the repo root AND nested
321
+ // under a package subdirectory (e.g. monorepos with frontend/app/... instead of
322
+ // app/...) a root-only 'app/%' pattern silently misses every monorepo layout.
323
+ const frameworkExclude = `AND s.file_path NOT LIKE 'app/%/page.%' AND s.file_path NOT LIKE '%/app/%/page.%'
324
+ AND s.file_path NOT LIKE 'app/%/layout.%' AND s.file_path NOT LIKE '%/app/%/layout.%'
325
+ AND s.file_path NOT LIKE 'app/page.%' AND s.file_path NOT LIKE '%/app/page.%'
326
+ AND s.file_path NOT LIKE 'app/layout.%' AND s.file_path NOT LIKE '%/app/layout.%'
327
+ AND s.file_path NOT LIKE 'app/%/loading.%' AND s.file_path NOT LIKE '%/app/%/loading.%'
328
+ AND s.file_path NOT LIKE 'app/%/error.%' AND s.file_path NOT LIKE '%/app/%/error.%'
329
+ AND s.file_path NOT LIKE 'app/%/not-found.%' AND s.file_path NOT LIKE '%/app/%/not-found.%'
330
+ AND s.file_path NOT LIKE 'app/%/template.%' AND s.file_path NOT LIKE '%/app/%/template.%'
331
+ AND s.file_path NOT LIKE 'app/%/global-error.%' AND s.file_path NOT LIKE '%/app/%/global-error.%'
332
+ AND s.file_path NOT LIKE 'app/%/default.%' AND s.file_path NOT LIKE '%/app/%/default.%'
333
+ AND s.file_path NOT LIKE 'app/api/%/route.%' AND s.file_path NOT LIKE '%/app/api/%/route.%'
334
+ AND s.file_path NOT LIKE 'app/%/route.%' AND s.file_path NOT LIKE '%/app/%/route.%'
335
+ AND s.file_path NOT LIKE 'jest.config.%'
336
+ AND s.file_path NOT LIKE 'src/routes/+page.%' AND s.file_path NOT LIKE '%/src/routes/+page.%'
337
+ AND s.file_path NOT LIKE 'src/routes/+layout.%' AND s.file_path NOT LIKE '%/src/routes/+layout.%'
338
+ AND s.file_path NOT LIKE '%/alembic/versions/%'
339
+ AND s.file_path NOT LIKE '%/migrations/%'
340
+ AND s.file_path NOT LIKE 'api/%' AND s.file_path NOT LIKE '%/api/%'
341
+ AND NOT ((s.file_path LIKE 'app/%' OR s.file_path LIKE '%/app/%') AND s.name IN ('generateStaticParams', 'metadata', 'generateMetadata', 'viewport', 'generateViewport', 'revalidate', 'dynamic', 'fetchCache', 'runtime', 'preferredRegion', 'maxDuration'))`;
292
342
  // Get ALL exported symbols that HAVE at least one incoming link (not caught by findDeadCode).
293
343
  // No SQL LIMIT here: the JS post-filter below narrows this down to test-only-referenced
294
344
  // symbols, which can be a small minority of low-heat candidates — applying `limit` before
295
345
  // that filter would silently drop real orphans that don't happen to rank in the top N by heat.
296
- const sql = `SELECT s.*, COUNT(l.id) as incoming_count FROM symbols s
297
- JOIN links l ON l.to_id = s.id AND l.type != 'contains'
298
- WHERE s.exported = 1 AND s.kind != 'section'
299
- ${frameworkExclude}
300
- GROUP BY s.id
301
- HAVING incoming_count > 0
346
+ const sql = `SELECT s.*, COUNT(l.id) as incoming_count FROM symbols s
347
+ JOIN links l ON l.to_id = s.id AND l.type != 'contains'
348
+ WHERE s.exported = 1 AND s.kind != 'section'
349
+ ${frameworkExclude}
350
+ GROUP BY s.id
351
+ HAVING incoming_count > 0
302
352
  ORDER BY s.heat DESC`;
303
353
  const rows = this.db.prepare(sql).all();
304
354
  const candidates = rows.map(rowToSymbol);
@@ -319,25 +369,25 @@ export class Database {
319
369
  return results.slice(0, limit);
320
370
  }
321
371
  getTypeHierarchy(symbolId) {
322
- const ancestors = this.db.prepare(`
323
- WITH RECURSIVE up(id, depth) AS (
324
- SELECT to_id, 1 FROM links WHERE from_id = ? AND type IN ('extends', 'implements')
325
- UNION
326
- SELECT l.to_id, u.depth + 1
327
- FROM links l JOIN up u ON l.from_id = u.id
328
- WHERE l.type IN ('extends', 'implements') AND u.depth < 10
329
- )
330
- SELECT DISTINCT s.*, u.depth FROM up u JOIN symbols s ON s.id = u.id ORDER BY u.depth
372
+ const ancestors = this.db.prepare(`
373
+ WITH RECURSIVE up(id, depth) AS (
374
+ SELECT to_id, 1 FROM links WHERE from_id = ? AND type IN ('extends', 'implements')
375
+ UNION
376
+ SELECT l.to_id, u.depth + 1
377
+ FROM links l JOIN up u ON l.from_id = u.id
378
+ WHERE l.type IN ('extends', 'implements') AND u.depth < 10
379
+ )
380
+ SELECT DISTINCT s.*, u.depth FROM up u JOIN symbols s ON s.id = u.id ORDER BY u.depth
331
381
  `).all(symbolId);
332
- const descendants = this.db.prepare(`
333
- WITH RECURSIVE down(id, depth) AS (
334
- SELECT from_id, 1 FROM links WHERE to_id = ? AND type IN ('extends', 'implements')
335
- UNION
336
- SELECT l.from_id, d.depth + 1
337
- FROM links l JOIN down d ON l.to_id = d.id
338
- WHERE l.type IN ('extends', 'implements') AND d.depth < 10
339
- )
340
- SELECT DISTINCT s.*, d.depth FROM down d JOIN symbols s ON s.id = d.id ORDER BY d.depth
382
+ const descendants = this.db.prepare(`
383
+ WITH RECURSIVE down(id, depth) AS (
384
+ SELECT from_id, 1 FROM links WHERE to_id = ? AND type IN ('extends', 'implements')
385
+ UNION
386
+ SELECT l.from_id, d.depth + 1
387
+ FROM links l JOIN down d ON l.to_id = d.id
388
+ WHERE l.type IN ('extends', 'implements') AND d.depth < 10
389
+ )
390
+ SELECT DISTINCT s.*, d.depth FROM down d JOIN symbols s ON s.id = d.id ORDER BY d.depth
341
391
  `).all(symbolId);
342
392
  return {
343
393
  ancestors: ancestors.map(r => ({ symbol: rowToSymbol(r), depth: r.depth })),
@@ -349,21 +399,52 @@ export class Database {
349
399
  const toSyms = this.findSymbolByName(toName);
350
400
  if (fromSyms.length === 0 || toSyms.length === 0)
351
401
  return null;
352
- const fromId = fromSyms[0].id;
353
- const toId = toSyms[0].id;
354
- const toIds = new Set(toSyms.map(s => s.id));
355
- const rows = this.db.prepare(`
356
- WITH RECURSIVE chain(node_id, depth, via, path_ids) AS (
357
- SELECT l.to_id, 1, l.type, ',' || l.from_id || ',' || l.to_id || ','
358
- FROM links l WHERE l.from_id = ? AND l.type != 'contains'
359
- UNION ALL
360
- SELECT l.to_id, c.depth + 1, l.type, c.path_ids || l.to_id || ','
361
- FROM links l JOIN chain c ON l.from_id = c.node_id
362
- WHERE l.type != 'contains' AND c.depth < ?
363
- AND c.path_ids NOT LIKE '%,' || l.to_id || ',%'
364
- )
365
- SELECT node_id, depth, via, path_ids FROM chain ORDER BY depth
366
- `).all(fromId, maxDepth);
402
+ return this.findPathFromId(fromSyms[0].id, toSyms.map(s => s.id), maxDepth);
403
+ }
404
+ /**
405
+ * Same as findPath, but takes an already-resolved source symbol id instead
406
+ * of re-resolving by name. Needed when the caller has disambiguated among
407
+ * several same-named symbols (e.g. picking the right file-scoped `_top`
408
+ * module out of many) passing the name back through findPath() would
409
+ * silently re-resolve to an arbitrary same-named symbol and discard that
410
+ * disambiguation.
411
+ */
412
+ findPathFromId(fromId, toNameOrIds, maxDepth = 5) {
413
+ const toIds = Array.isArray(toNameOrIds)
414
+ ? new Set(toNameOrIds)
415
+ : new Set(this.findSymbolByName(toNameOrIds).map(s => s.id));
416
+ if (toIds.size === 0)
417
+ return null;
418
+ // Anchor branch 2 seeds the search by descending ONE 'contains' hop from the exact
419
+ // origin symbol into its own contained methods, then taking one real edge from there.
420
+ // This only ever applies to the literal starting symbol (later branches never re-descend
421
+ // into contains children mid-path) — a class/struct/trait passed as `fromId` typically
422
+ // has zero direct calls/imports edges of its own (its real behavior lives in its
423
+ // methods), so without this seed the search dead-ends immediately at depth 0 for any
424
+ // class-to-X query. Safe: it can't produce a false "relationship" between unrelated
425
+ // sibling methods, since it's scoped to fromId's own children only, never applied again
426
+ // to a class reached later in the chain.
427
+ const rows = this.db.prepare(`
428
+ WITH RECURSIVE chain(node_id, depth, via, path_ids) AS (
429
+ SELECT l.to_id, 1, l.type, ',' || l.from_id || ',' || l.to_id || ','
430
+ FROM links l WHERE l.from_id = ? AND l.type != 'contains'
431
+ UNION ALL
432
+ SELECT l2.to_id, 2, l2.type, ',' || l.from_id || ',' || l.to_id || ',' || l2.to_id || ','
433
+ FROM links l JOIN links l2 ON l2.from_id = l.to_id
434
+ WHERE l.from_id = ? AND l.type = 'contains' AND l2.type != 'contains'
435
+ UNION ALL
436
+ SELECT l.to_id, c.depth + 1, l.type, c.path_ids || l.to_id || ','
437
+ FROM links l JOIN chain c ON l.from_id = c.node_id
438
+ WHERE l.type != 'contains' AND c.depth < ?
439
+ AND c.path_ids NOT LIKE '%,' || l.to_id || ',%'
440
+ UNION ALL
441
+ SELECT l.from_id, c.depth + 1, l.type, c.path_ids || l.from_id || ','
442
+ FROM links l JOIN chain c ON l.to_id = c.node_id
443
+ WHERE l.type = 'contains' AND c.depth < ?
444
+ AND c.path_ids NOT LIKE '%,' || l.from_id || ',%'
445
+ )
446
+ SELECT node_id, depth, via, path_ids FROM chain ORDER BY depth
447
+ `).all(fromId, fromId, maxDepth, maxDepth);
367
448
  // Find the first (shortest-depth) row matching the target
368
449
  const targetRow = rows.find(r => toIds.has(r.node_id));
369
450
  if (!targetRow)
@@ -376,12 +457,21 @@ export class Database {
376
457
  const sym = this.findSymbolById(idChain[i]);
377
458
  if (!sym)
378
459
  continue;
379
- // Determine via for this hop: lookup link from idChain[i] → idChain[i+1]
460
+ // Determine via for this hop: lookup link from idChain[i] → idChain[i+1]. Prefer a
461
+ // real calls/imports/etc. edge when one exists between the two nodes; only report
462
+ // 'contains' (in either direction — the traversal now allows both a class-to-method
463
+ // seed step and a method-to-class arrival step) when that's the only relationship.
380
464
  let via = 'calls';
381
465
  if (i < idChain.length - 1) {
382
466
  const linkRow = this.db.prepare('SELECT type FROM links WHERE from_id = ? AND to_id = ? AND type != ? LIMIT 1').get(idChain[i], idChain[i + 1], 'contains');
383
- if (linkRow)
467
+ if (linkRow) {
384
468
  via = linkRow.type;
469
+ }
470
+ else {
471
+ const containsRow = this.db.prepare('SELECT type FROM links WHERE ((from_id = ? AND to_id = ?) OR (from_id = ? AND to_id = ?)) AND type = ? LIMIT 1').get(idChain[i], idChain[i + 1], idChain[i + 1], idChain[i], 'contains');
472
+ if (containsRow)
473
+ via = containsRow.type;
474
+ }
385
475
  }
386
476
  result.push({ symbol: sym, depth: i, via });
387
477
  }
@@ -427,14 +517,14 @@ export class Database {
427
517
  };
428
518
  }
429
519
  getConfidenceDistribution() {
430
- const rows = this.db.prepare(`
431
- SELECT
432
- SUM(CASE WHEN confidence >= 0.9 THEN 1 ELSE 0 END) as high,
433
- SUM(CASE WHEN confidence >= 0.7 AND confidence < 0.9 THEN 1 ELSE 0 END) as medium,
434
- SUM(CASE WHEN confidence < 0.7 THEN 1 ELSE 0 END) as low,
435
- COUNT(*) as total
436
- FROM links
437
- WHERE type != 'contains'
520
+ const rows = this.db.prepare(`
521
+ SELECT
522
+ SUM(CASE WHEN confidence >= 0.9 THEN 1 ELSE 0 END) as high,
523
+ SUM(CASE WHEN confidence >= 0.7 AND confidence < 0.9 THEN 1 ELSE 0 END) as medium,
524
+ SUM(CASE WHEN confidence < 0.7 THEN 1 ELSE 0 END) as low,
525
+ COUNT(*) as total
526
+ FROM links
527
+ WHERE type != 'contains'
438
528
  `).get();
439
529
  return {
440
530
  high: rows?.high ?? 0,
@@ -459,7 +549,16 @@ export class Database {
459
549
  if (incoming.length === 0) {
460
550
  // Reached an entrypoint — save this path
461
551
  // _top modules represent file-level entrypoints (e.g., top-level code execution)
462
- if (sym?.exported || (sym?.kind === 'module' && sym?.name === '_top')) {
552
+ // Class methods: if the method is contained by an exported class, treat it as
553
+ // a de facto entrypoint (framework-invoked handlers like NestJS controllers).
554
+ const isExportedClassMember = !sym?.exported && sym?.kind === 'method' &&
555
+ this.getIncomingLinks(currentId).some(l => {
556
+ if (l.type !== 'contains')
557
+ return false;
558
+ const containingClass = this.findSymbolById(l.fromId);
559
+ return containingClass?.exported === true;
560
+ });
561
+ if (sym?.exported || (sym?.kind === 'module' && sym?.name === '_top') || isExportedClassMember) {
463
562
  paths.push({ path: [...currentPath] });
464
563
  }
465
564
  visited.delete(currentId);
@@ -490,34 +589,34 @@ export class Database {
490
589
  // ── Route/endpoint detection via link patterns ──
491
590
  getEntrypoints() {
492
591
  // Symbols with role='entrypoint' OR exported + 0 incoming non-contains links
493
- const rows = this.db.prepare(`
494
- SELECT s.* FROM symbols s
495
- WHERE s.exported = 1
496
- AND s.role = 'entrypoint'
497
- ORDER BY s.heat DESC
498
- LIMIT 50
592
+ const rows = this.db.prepare(`
593
+ SELECT s.* FROM symbols s
594
+ WHERE s.exported = 1
595
+ AND s.role = 'entrypoint'
596
+ ORDER BY s.heat DESC
597
+ LIMIT 50
499
598
  `).all();
500
599
  return rows.map(rowToSymbol);
501
600
  }
502
601
  // ── Domain clustering stats ──
503
602
  getDomainStats() {
504
- const rows = this.db.prepare(`
505
- SELECT fh.zone AS domain, COUNT(DISTINCT fh.path) AS file_count,
506
- COUNT(s.id) AS symbol_count
507
- FROM file_hashes fh
508
- LEFT JOIN symbols s ON s.file_path = fh.path
509
- WHERE fh.zone IS NOT NULL
510
- GROUP BY fh.zone
511
- ORDER BY symbol_count DESC
603
+ const rows = this.db.prepare(`
604
+ SELECT fh.zone AS domain, COUNT(DISTINCT fh.path) AS file_count,
605
+ COUNT(s.id) AS symbol_count
606
+ FROM file_hashes fh
607
+ LEFT JOIN symbols s ON s.file_path = fh.path
608
+ WHERE fh.zone IS NOT NULL
609
+ GROUP BY fh.zone
610
+ ORDER BY symbol_count DESC
512
611
  `).all();
513
612
  return rows.map((r) => ({ domain: r.domain, files: r.file_count, symbols: r.symbol_count }));
514
613
  }
515
614
  // ── Staleness detection ──
516
615
  getStaleFiles(hoursOld = 24) {
517
- const rows = this.db.prepare(`
518
- SELECT path FROM file_hashes
519
- WHERE analyzed_at < datetime('now', '-' || ? || ' hours')
520
- ORDER BY analyzed_at ASC
616
+ const rows = this.db.prepare(`
617
+ SELECT path FROM file_hashes
618
+ WHERE analyzed_at < datetime('now', '-' || ? || ' hours')
619
+ ORDER BY analyzed_at ASC
521
620
  `).all(hoursOld);
522
621
  return rows.map((r) => r.path);
523
622
  }
@@ -543,14 +642,14 @@ export class Database {
543
642
  return;
544
643
  const placeholders = filePaths.map(() => '?').join(',');
545
644
  // Delete links where either end references a symbol in the changed files
546
- this.db.prepare(`
547
- DELETE FROM links WHERE from_id IN (SELECT id FROM symbols WHERE file_path IN (${placeholders}))
645
+ this.db.prepare(`
646
+ DELETE FROM links WHERE from_id IN (SELECT id FROM symbols WHERE file_path IN (${placeholders}))
548
647
  `).run(...filePaths);
549
- this.db.prepare(`
550
- DELETE FROM links WHERE to_id IN (SELECT id FROM symbols WHERE file_path IN (${placeholders}))
648
+ this.db.prepare(`
649
+ DELETE FROM links WHERE to_id IN (SELECT id FROM symbols WHERE file_path IN (${placeholders}))
551
650
  `).run(...filePaths);
552
- this.db.prepare(`
553
- DELETE FROM symbols WHERE file_path IN (${placeholders})
651
+ this.db.prepare(`
652
+ DELETE FROM symbols WHERE file_path IN (${placeholders})
554
653
  `).run(...filePaths);
555
654
  }
556
655
  /** Delete file_hashes rows for paths not in the given set (orphan cleanup after incremental analyze) */
@@ -558,52 +657,52 @@ export class Database {
558
657
  if (knownPaths.length === 0)
559
658
  return 0;
560
659
  const placeholders = knownPaths.map(() => '?').join(',');
561
- const result = this.db.prepare(`
562
- DELETE FROM file_hashes WHERE path NOT IN (${placeholders})
660
+ const result = this.db.prepare(`
661
+ DELETE FROM file_hashes WHERE path NOT IN (${placeholders})
563
662
  `).run(...knownPaths);
564
663
  return result.changes;
565
664
  }
566
665
  // ── Tool usage tracking ──
567
666
  logToolUsage(tool, durationMs, tokensOut, tokensSaved, repo) {
568
- this.db.prepare(`INSERT INTO tool_usage (tool, duration_ms, tokens_out, tokens_saved, repo)
667
+ this.db.prepare(`INSERT INTO tool_usage (tool, duration_ms, tokens_out, tokens_saved, repo)
569
668
  VALUES (?, ?, ?, ?, ?)`).run(tool, durationMs, tokensOut, tokensSaved, repo ?? null);
570
669
  }
571
670
  getToolUsageStats(repo) {
572
671
  const repoFilter = repo ? `WHERE repo = ?` : '';
573
672
  const repoParam = repo ? [repo] : [];
574
- const totals = this.db.prepare(`
575
- SELECT COUNT(*) as total_calls,
576
- COALESCE(SUM(tokens_saved), 0) as total_saved,
577
- COALESCE(SUM(tokens_out), 0) as total_out,
578
- COALESCE(SUM(duration_ms), 0) as total_ms
579
- FROM tool_usage
580
- ${repoFilter}
673
+ const totals = this.db.prepare(`
674
+ SELECT COUNT(*) as total_calls,
675
+ COALESCE(SUM(tokens_saved), 0) as total_saved,
676
+ COALESCE(SUM(tokens_out), 0) as total_out,
677
+ COALESCE(SUM(duration_ms), 0) as total_ms
678
+ FROM tool_usage
679
+ ${repoFilter}
581
680
  `).get(...repoParam);
582
- const byTool = this.db.prepare(`
583
- SELECT tool, COUNT(*) as calls,
584
- COALESCE(SUM(tokens_saved), 0) as tokens_saved,
585
- COALESCE(SUM(tokens_out), 0) as tokens_out,
586
- CAST(COALESCE(AVG(duration_ms), 0) AS INTEGER) as avg_ms
587
- FROM tool_usage
588
- ${repoFilter}
589
- GROUP BY tool
590
- ORDER BY calls DESC
681
+ const byTool = this.db.prepare(`
682
+ SELECT tool, COUNT(*) as calls,
683
+ COALESCE(SUM(tokens_saved), 0) as tokens_saved,
684
+ COALESCE(SUM(tokens_out), 0) as tokens_out,
685
+ CAST(COALESCE(AVG(duration_ms), 0) AS INTEGER) as avg_ms
686
+ FROM tool_usage
687
+ ${repoFilter}
688
+ GROUP BY tool
689
+ ORDER BY calls DESC
591
690
  `).all(...repoParam);
592
- const byDay = this.db.prepare(`
593
- SELECT date(called_at) as date, COUNT(*) as calls,
594
- COALESCE(SUM(tokens_saved), 0) as tokens_saved
595
- FROM tool_usage
596
- ${repoFilter}
597
- GROUP BY date(called_at)
598
- ORDER BY date DESC
599
- LIMIT 30
691
+ const byDay = this.db.prepare(`
692
+ SELECT date(called_at) as date, COUNT(*) as calls,
693
+ COALESCE(SUM(tokens_saved), 0) as tokens_saved
694
+ FROM tool_usage
695
+ ${repoFilter}
696
+ GROUP BY date(called_at)
697
+ ORDER BY date DESC
698
+ LIMIT 30
600
699
  `).all(...repoParam);
601
- const recentCalls = this.db.prepare(`
602
- SELECT tool, called_at, duration_ms, tokens_saved
603
- FROM tool_usage
604
- ${repoFilter}
605
- ORDER BY id DESC
606
- LIMIT 50
700
+ const recentCalls = this.db.prepare(`
701
+ SELECT tool, called_at, duration_ms, tokens_saved
702
+ FROM tool_usage
703
+ ${repoFilter}
704
+ ORDER BY id DESC
705
+ LIMIT 50
607
706
  `).all(...repoParam);
608
707
  return {
609
708
  totalCalls: totals.total_calls,
@@ -622,7 +721,10 @@ export class Database {
622
721
  }
623
722
  // ── Test file detection ──
624
723
  isTestFile(filePath) {
625
- return /[/\\]test[/\\]/.test(filePath) || /\.(test|spec)\./.test(filePath);
724
+ const codeExt = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|java|php|rs)$/i;
725
+ if (!codeExt.test(filePath))
726
+ return false;
727
+ return /[/\\]tests?[/\\]/.test(filePath) || /\.(test|spec)\./i.test(filePath);
626
728
  }
627
729
  // ── Heat / hubs ──
628
730
  getTopHubs(limit) {
@@ -644,10 +746,10 @@ export class Database {
644
746
  if (candidateIds.length === 0)
645
747
  return new Set();
646
748
  const placeholders = candidateIds.map(() => '?').join(',');
647
- const rows = this.db.prepare(`
648
- SELECT DISTINCT l.to_id
649
- FROM links l JOIN symbols src ON src.id = l.from_id
650
- WHERE l.to_id IN (${placeholders}) AND l.type != 'contains'
749
+ const rows = this.db.prepare(`
750
+ SELECT DISTINCT l.to_id
751
+ FROM links l JOIN symbols src ON src.id = l.from_id
752
+ WHERE l.to_id IN (${placeholders}) AND l.type != 'contains'
651
753
  `).all(...candidateIds);
652
754
  const testedIds = new Set();
653
755
  for (const row of rows) {
@@ -682,27 +784,27 @@ export class Database {
682
784
  if (changedSymbolIds.length === 0)
683
785
  return { testFiles: [], changedSymbols: [] };
684
786
  const placeholders = changedSymbolIds.map(() => '?').join(',');
685
- const testFilesDirect = this.db.prepare(`
686
- SELECT DISTINCT src.file_path
687
- FROM links l
688
- JOIN symbols src ON src.id = l.from_id
689
- WHERE l.to_id IN (${placeholders})
690
- AND (src.file_path LIKE '%/test/%' OR src.file_path LIKE '%\\test\\%'
691
- OR src.file_path LIKE '%.test.%' OR src.file_path LIKE '%.spec.%')
787
+ const testFilesDirect = this.db.prepare(`
788
+ SELECT DISTINCT src.file_path
789
+ FROM links l
790
+ JOIN symbols src ON src.id = l.from_id
791
+ WHERE l.to_id IN (${placeholders})
792
+ AND (src.file_path LIKE '%/test/%' OR src.file_path LIKE '%\\test\\%'
793
+ OR src.file_path LIKE '%.test.%' OR src.file_path LIKE '%.spec.%')
692
794
  `).all(...changedSymbolIds);
693
- const testFilesUpstream = this.db.prepare(`
694
- WITH RECURSIVE upstream(id, depth) AS (
695
- SELECT from_id, 1 FROM links WHERE to_id IN (${placeholders})
696
- AND type IN ('calls', 'imports', 'extends', 'implements')
697
- UNION
698
- SELECT l.from_id, u.depth + 1
699
- FROM links l JOIN upstream u ON l.to_id = u.id
700
- WHERE l.type IN ('calls', 'imports', 'extends', 'implements') AND u.depth < 5
701
- )
702
- SELECT DISTINCT s.file_path FROM upstream u
703
- JOIN symbols s ON s.id = u.id
704
- WHERE (s.file_path LIKE '%/test/%' OR s.file_path LIKE '%\\test\\%'
705
- OR s.file_path LIKE '%.test.%' OR s.file_path LIKE '%.spec.%')
795
+ const testFilesUpstream = this.db.prepare(`
796
+ WITH RECURSIVE upstream(id, depth) AS (
797
+ SELECT from_id, 1 FROM links WHERE to_id IN (${placeholders})
798
+ AND type IN ('calls', 'imports', 'extends', 'implements')
799
+ UNION
800
+ SELECT l.from_id, u.depth + 1
801
+ FROM links l JOIN upstream u ON l.to_id = u.id
802
+ WHERE l.type IN ('calls', 'imports', 'extends', 'implements') AND u.depth < 5
803
+ )
804
+ SELECT DISTINCT s.file_path FROM upstream u
805
+ JOIN symbols s ON s.id = u.id
806
+ WHERE (s.file_path LIKE '%/test/%' OR s.file_path LIKE '%\\test\\%'
807
+ OR s.file_path LIKE '%.test.%' OR s.file_path LIKE '%.spec.%')
706
808
  `).all(...changedSymbolIds);
707
809
  const allTestFiles = [...new Set([
708
810
  ...testFilesDirect.map((r) => r.file_path),
@@ -736,30 +838,41 @@ export class Database {
736
838
  const target = this.findSymbolById(symbolId);
737
839
  if (!target)
738
840
  return [];
739
- const incoming = this.getIncomingLinks(symbolId).filter(l => l.type !== 'contains');
740
- const outgoing = this.getOutgoingLinks(symbolId).filter(l => l.type !== 'contains');
741
- const targetLinks = new Set();
742
- for (const link of incoming)
743
- targetLinks.add(link.fromId);
744
- for (const link of outgoing)
745
- targetLinks.add(link.toId);
841
+ const CONTAINER_KINDS = new Set(['class', 'struct', 'trait']);
842
+ const buildSig = (id) => {
843
+ const sig = new Set();
844
+ const sym = this.findSymbolById(id);
845
+ const incoming = this.getIncomingLinks(id).filter(l => l.type !== 'contains');
846
+ const outgoing = this.getOutgoingLinks(id).filter(l => l.type !== 'contains');
847
+ for (const link of incoming)
848
+ sig.add(link.fromId);
849
+ for (const link of outgoing)
850
+ sig.add(link.toId);
851
+ if (sym && CONTAINER_KINDS.has(sym.kind)) {
852
+ const contained = this.getOutgoingLinks(id).filter(l => l.type === 'contains');
853
+ for (const cl of contained) {
854
+ const mIn = this.getIncomingLinks(cl.toId).filter(l => l.type !== 'contains');
855
+ const mOut = this.getOutgoingLinks(cl.toId).filter(l => l.type !== 'contains');
856
+ for (const link of mIn)
857
+ sig.add(link.fromId);
858
+ for (const link of mOut)
859
+ sig.add(link.toId);
860
+ }
861
+ }
862
+ return sig;
863
+ };
864
+ const targetSig = buildSig(symbolId);
746
865
  // Search across all exported symbols, not just same-file siblings
747
866
  const allSymbols = this.getAllSymbols().filter(s => s.id !== symbolId && s.exported);
748
867
  const results = [];
749
868
  for (const candidate of allSymbols) {
750
- const candIncoming = this.getIncomingLinks(candidate.id).filter(l => l.type !== 'contains');
751
- const candOutgoing = this.getOutgoingLinks(candidate.id).filter(l => l.type !== 'contains');
752
- const candidateLinks = new Set();
753
- for (const link of candIncoming)
754
- candidateLinks.add(link.fromId);
755
- for (const link of candOutgoing)
756
- candidateLinks.add(link.toId);
869
+ const candidateSig = buildSig(candidate.id);
757
870
  let intersection = 0;
758
- for (const id of targetLinks) {
759
- if (candidateLinks.has(id))
871
+ for (const id of targetSig) {
872
+ if (candidateSig.has(id))
760
873
  intersection++;
761
874
  }
762
- const union = new Set([...targetLinks, ...candidateLinks]).size;
875
+ const union = new Set([...targetSig, ...candidateSig]).size;
763
876
  const similarity = union > 0 ? intersection / union : 0;
764
877
  if (similarity >= 0.15) {
765
878
  results.push({ symbol: candidate, similarity: Math.round(similarity * 100) / 100 });
@@ -778,7 +891,7 @@ export class Database {
778
891
  const expiresAt = ttlHours
779
892
  ? new Date(Date.now() + ttlHours * 3600_000).toISOString().replace('T', ' ').slice(0, 19)
780
893
  : null;
781
- const result = this.db.prepare(`INSERT INTO annotations (symbol, key, value, agent, session_id, expires_at)
894
+ const result = this.db.prepare(`INSERT INTO annotations (symbol, key, value, agent, session_id, expires_at)
782
895
  VALUES (?, ?, ?, ?, ?, ?)`).run(symbolId, key, value, agent ?? null, sessionId ?? null, expiresAt);
783
896
  return result.lastInsertRowid;
784
897
  }
@@ -833,12 +946,12 @@ export class Database {
833
946
  this.db.prepare('INSERT INTO metric_history (metric_name, value) VALUES (?, ?)').run(name, value);
834
947
  }
835
948
  getMetricHistory(name, daysBack = 30) {
836
- return this.db.prepare(`SELECT value, recorded_at as recordedAt FROM metric_history
837
- WHERE metric_name = ? AND recorded_at >= datetime('now', ?)
949
+ return this.db.prepare(`SELECT value, recorded_at as recordedAt FROM metric_history
950
+ WHERE metric_name = ? AND recorded_at >= datetime('now', ?)
838
951
  ORDER BY recorded_at DESC`).all(name, `-${daysBack} days`);
839
952
  }
840
953
  getMetricTrend(name) {
841
- const rows = this.db.prepare(`SELECT value FROM metric_history
954
+ const rows = this.db.prepare(`SELECT value FROM metric_history
842
955
  WHERE metric_name = ? ORDER BY recorded_at DESC LIMIT 2`).all(name);
843
956
  if (rows.length === 0)
844
957
  return { current: 0, previous: null, change: null };