@optave/codegraph 3.1.2 → 3.1.4

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 (194) hide show
  1. package/README.md +19 -21
  2. package/package.json +10 -7
  3. package/src/analysis/context.js +408 -0
  4. package/src/analysis/dependencies.js +341 -0
  5. package/src/analysis/exports.js +130 -0
  6. package/src/analysis/impact.js +463 -0
  7. package/src/analysis/module-map.js +322 -0
  8. package/src/analysis/roles.js +45 -0
  9. package/src/analysis/symbol-lookup.js +232 -0
  10. package/src/ast-analysis/shared.js +5 -4
  11. package/src/batch.js +2 -1
  12. package/src/builder/context.js +85 -0
  13. package/src/builder/helpers.js +218 -0
  14. package/src/builder/incremental.js +178 -0
  15. package/src/builder/pipeline.js +130 -0
  16. package/src/builder/stages/build-edges.js +297 -0
  17. package/src/builder/stages/build-structure.js +113 -0
  18. package/src/builder/stages/collect-files.js +44 -0
  19. package/src/builder/stages/detect-changes.js +413 -0
  20. package/src/builder/stages/finalize.js +139 -0
  21. package/src/builder/stages/insert-nodes.js +195 -0
  22. package/src/builder/stages/parse-files.js +28 -0
  23. package/src/builder/stages/resolve-imports.js +143 -0
  24. package/src/builder/stages/run-analyses.js +44 -0
  25. package/src/builder.js +10 -1472
  26. package/src/cfg.js +1 -2
  27. package/src/cli/commands/ast.js +26 -0
  28. package/src/cli/commands/audit.js +46 -0
  29. package/src/cli/commands/batch.js +68 -0
  30. package/src/cli/commands/branch-compare.js +21 -0
  31. package/src/cli/commands/build.js +26 -0
  32. package/src/cli/commands/cfg.js +30 -0
  33. package/src/cli/commands/check.js +79 -0
  34. package/src/cli/commands/children.js +31 -0
  35. package/src/cli/commands/co-change.js +65 -0
  36. package/src/cli/commands/communities.js +23 -0
  37. package/src/cli/commands/complexity.js +45 -0
  38. package/src/cli/commands/context.js +34 -0
  39. package/src/cli/commands/cycles.js +28 -0
  40. package/src/cli/commands/dataflow.js +32 -0
  41. package/src/cli/commands/deps.js +16 -0
  42. package/src/cli/commands/diff-impact.js +30 -0
  43. package/src/cli/commands/embed.js +30 -0
  44. package/src/cli/commands/export.js +75 -0
  45. package/src/cli/commands/exports.js +18 -0
  46. package/src/cli/commands/flow.js +36 -0
  47. package/src/cli/commands/fn-impact.js +30 -0
  48. package/src/cli/commands/impact.js +16 -0
  49. package/src/cli/commands/info.js +76 -0
  50. package/src/cli/commands/map.js +19 -0
  51. package/src/cli/commands/mcp.js +18 -0
  52. package/src/cli/commands/models.js +19 -0
  53. package/src/cli/commands/owners.js +25 -0
  54. package/src/cli/commands/path.js +36 -0
  55. package/src/cli/commands/plot.js +80 -0
  56. package/src/cli/commands/query.js +49 -0
  57. package/src/cli/commands/registry.js +100 -0
  58. package/src/cli/commands/roles.js +34 -0
  59. package/src/cli/commands/search.js +42 -0
  60. package/src/cli/commands/sequence.js +32 -0
  61. package/src/cli/commands/snapshot.js +61 -0
  62. package/src/cli/commands/stats.js +15 -0
  63. package/src/cli/commands/structure.js +32 -0
  64. package/src/cli/commands/triage.js +78 -0
  65. package/src/cli/commands/watch.js +12 -0
  66. package/src/cli/commands/where.js +24 -0
  67. package/src/cli/index.js +118 -0
  68. package/src/cli/shared/options.js +39 -0
  69. package/src/cli/shared/output.js +1 -0
  70. package/src/cli.js +11 -1514
  71. package/src/commands/check.js +5 -5
  72. package/src/commands/manifesto.js +3 -3
  73. package/src/commands/structure.js +1 -1
  74. package/src/communities.js +15 -87
  75. package/src/complexity.js +1 -1
  76. package/src/cycles.js +30 -85
  77. package/src/dataflow.js +1 -2
  78. package/src/db/connection.js +4 -4
  79. package/src/db/migrations.js +41 -0
  80. package/src/db/query-builder.js +6 -5
  81. package/src/db/repository/base.js +201 -0
  82. package/src/db/repository/cached-stmt.js +19 -0
  83. package/src/db/repository/cfg.js +27 -38
  84. package/src/db/repository/cochange.js +16 -3
  85. package/src/db/repository/complexity.js +11 -6
  86. package/src/db/repository/dataflow.js +6 -1
  87. package/src/db/repository/edges.js +120 -98
  88. package/src/db/repository/embeddings.js +14 -3
  89. package/src/db/repository/graph-read.js +32 -9
  90. package/src/db/repository/in-memory-repository.js +584 -0
  91. package/src/db/repository/index.js +6 -1
  92. package/src/db/repository/nodes.js +110 -40
  93. package/src/db/repository/sqlite-repository.js +219 -0
  94. package/src/db.js +5 -0
  95. package/src/embeddings/generator.js +163 -0
  96. package/src/embeddings/index.js +13 -0
  97. package/src/embeddings/models.js +218 -0
  98. package/src/embeddings/search/cli-formatter.js +151 -0
  99. package/src/embeddings/search/filters.js +46 -0
  100. package/src/embeddings/search/hybrid.js +121 -0
  101. package/src/embeddings/search/keyword.js +68 -0
  102. package/src/embeddings/search/prepare.js +66 -0
  103. package/src/embeddings/search/semantic.js +145 -0
  104. package/src/embeddings/stores/fts5.js +27 -0
  105. package/src/embeddings/stores/sqlite-blob.js +24 -0
  106. package/src/embeddings/strategies/source.js +14 -0
  107. package/src/embeddings/strategies/structured.js +43 -0
  108. package/src/embeddings/strategies/text-utils.js +43 -0
  109. package/src/errors.js +78 -0
  110. package/src/export.js +217 -520
  111. package/src/extractors/csharp.js +10 -2
  112. package/src/extractors/go.js +3 -1
  113. package/src/extractors/helpers.js +71 -0
  114. package/src/extractors/java.js +9 -2
  115. package/src/extractors/javascript.js +38 -1
  116. package/src/extractors/php.js +3 -1
  117. package/src/extractors/python.js +14 -3
  118. package/src/extractors/rust.js +3 -1
  119. package/src/graph/algorithms/bfs.js +49 -0
  120. package/src/graph/algorithms/centrality.js +16 -0
  121. package/src/graph/algorithms/index.js +5 -0
  122. package/src/graph/algorithms/louvain.js +26 -0
  123. package/src/graph/algorithms/shortest-path.js +41 -0
  124. package/src/graph/algorithms/tarjan.js +49 -0
  125. package/src/graph/builders/dependency.js +91 -0
  126. package/src/graph/builders/index.js +3 -0
  127. package/src/graph/builders/structure.js +40 -0
  128. package/src/graph/builders/temporal.js +33 -0
  129. package/src/graph/classifiers/index.js +2 -0
  130. package/src/graph/classifiers/risk.js +85 -0
  131. package/src/graph/classifiers/roles.js +64 -0
  132. package/src/graph/index.js +13 -0
  133. package/src/graph/model.js +230 -0
  134. package/src/index.js +33 -204
  135. package/src/infrastructure/result-formatter.js +2 -21
  136. package/src/mcp/index.js +2 -0
  137. package/src/mcp/middleware.js +26 -0
  138. package/src/mcp/server.js +128 -0
  139. package/src/mcp/tool-registry.js +801 -0
  140. package/src/mcp/tools/ast-query.js +14 -0
  141. package/src/mcp/tools/audit.js +21 -0
  142. package/src/mcp/tools/batch-query.js +11 -0
  143. package/src/mcp/tools/branch-compare.js +10 -0
  144. package/src/mcp/tools/cfg.js +21 -0
  145. package/src/mcp/tools/check.js +43 -0
  146. package/src/mcp/tools/co-changes.js +20 -0
  147. package/src/mcp/tools/code-owners.js +12 -0
  148. package/src/mcp/tools/communities.js +15 -0
  149. package/src/mcp/tools/complexity.js +18 -0
  150. package/src/mcp/tools/context.js +17 -0
  151. package/src/mcp/tools/dataflow.js +26 -0
  152. package/src/mcp/tools/diff-impact.js +24 -0
  153. package/src/mcp/tools/execution-flow.js +26 -0
  154. package/src/mcp/tools/export-graph.js +57 -0
  155. package/src/mcp/tools/file-deps.js +12 -0
  156. package/src/mcp/tools/file-exports.js +13 -0
  157. package/src/mcp/tools/find-cycles.js +15 -0
  158. package/src/mcp/tools/fn-impact.js +15 -0
  159. package/src/mcp/tools/impact-analysis.js +12 -0
  160. package/src/mcp/tools/index.js +71 -0
  161. package/src/mcp/tools/list-functions.js +14 -0
  162. package/src/mcp/tools/list-repos.js +11 -0
  163. package/src/mcp/tools/module-map.js +6 -0
  164. package/src/mcp/tools/node-roles.js +14 -0
  165. package/src/mcp/tools/path.js +12 -0
  166. package/src/mcp/tools/query.js +30 -0
  167. package/src/mcp/tools/semantic-search.js +65 -0
  168. package/src/mcp/tools/sequence.js +17 -0
  169. package/src/mcp/tools/structure.js +15 -0
  170. package/src/mcp/tools/symbol-children.js +14 -0
  171. package/src/mcp/tools/triage.js +35 -0
  172. package/src/mcp/tools/where.js +13 -0
  173. package/src/mcp.js +2 -1470
  174. package/src/native.js +34 -10
  175. package/src/parser.js +53 -2
  176. package/src/presentation/colors.js +44 -0
  177. package/src/presentation/export.js +444 -0
  178. package/src/presentation/result-formatter.js +21 -0
  179. package/src/presentation/sequence-renderer.js +43 -0
  180. package/src/presentation/table.js +47 -0
  181. package/src/presentation/viewer.js +634 -0
  182. package/src/queries.js +35 -2276
  183. package/src/resolve.js +1 -1
  184. package/src/sequence.js +2 -38
  185. package/src/shared/file-utils.js +153 -0
  186. package/src/shared/generators.js +125 -0
  187. package/src/shared/hierarchy.js +27 -0
  188. package/src/shared/normalize.js +59 -0
  189. package/src/snapshot.js +6 -5
  190. package/src/structure.js +15 -40
  191. package/src/triage.js +20 -72
  192. package/src/viewer.js +35 -656
  193. package/src/watcher.js +8 -148
  194. package/src/embedder.js +0 -1097
package/src/resolve.js CHANGED
@@ -178,4 +178,4 @@ export function resolveImportsBatch(inputs, rootDir, aliases, knownFiles) {
178
178
 
179
179
  // ── Exported for testing ────────────────────────────────────────────
180
180
 
181
- export { resolveImportPathJS, computeConfidenceJS };
181
+ export { computeConfidenceJS, resolveImportPathJS };
package/src/sequence.js CHANGED
@@ -285,41 +285,5 @@ export function sequenceData(name, dbPath, opts = {}) {
285
285
  }
286
286
  }
287
287
 
288
- // ─── Mermaid formatter ───────────────────────────────────────────────
289
-
290
- /**
291
- * Escape special Mermaid characters in labels.
292
- */
293
- function escapeMermaid(str) {
294
- return str
295
- .replace(/</g, '&lt;')
296
- .replace(/>/g, '&gt;')
297
- .replace(/:/g, '#colon;')
298
- .replace(/"/g, '#quot;');
299
- }
300
-
301
- /**
302
- * Convert sequenceData result to Mermaid sequenceDiagram syntax.
303
- * @param {{ participants, messages, truncated }} seqResult
304
- * @returns {string}
305
- */
306
- export function sequenceToMermaid(seqResult) {
307
- const lines = ['sequenceDiagram'];
308
-
309
- for (const p of seqResult.participants) {
310
- lines.push(` participant ${p.id} as ${escapeMermaid(p.label)}`);
311
- }
312
-
313
- for (const msg of seqResult.messages) {
314
- const arrow = msg.type === 'return' ? '-->>' : '->>';
315
- lines.push(` ${msg.from}${arrow}${msg.to}: ${escapeMermaid(msg.label)}`);
316
- }
317
-
318
- if (seqResult.truncated && seqResult.participants.length > 0) {
319
- lines.push(
320
- ` note right of ${seqResult.participants[0].id}: Truncated at depth ${seqResult.depth}`,
321
- );
322
- }
323
-
324
- return lines.join('\n');
325
- }
288
+ // Re-export Mermaid renderer from presentation layer
289
+ export { sequenceToMermaid } from './presentation/sequence-renderer.js';
@@ -0,0 +1,153 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { debug } from '../logger.js';
4
+ import { LANGUAGE_REGISTRY } from '../parser.js';
5
+
6
+ /**
7
+ * Resolve a file path relative to repoRoot, rejecting traversal outside the repo.
8
+ * Returns null if the resolved path escapes repoRoot.
9
+ */
10
+ export function safePath(repoRoot, file) {
11
+ const resolved = path.resolve(repoRoot, file);
12
+ if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) return null;
13
+ return resolved;
14
+ }
15
+
16
+ export function readSourceRange(repoRoot, file, startLine, endLine) {
17
+ try {
18
+ const absPath = safePath(repoRoot, file);
19
+ if (!absPath) return null;
20
+ const content = fs.readFileSync(absPath, 'utf-8');
21
+ const lines = content.split('\n');
22
+ const start = Math.max(0, (startLine || 1) - 1);
23
+ const end = Math.min(lines.length, endLine || startLine + 50);
24
+ return lines.slice(start, end).join('\n');
25
+ } catch (e) {
26
+ debug(`readSourceRange failed for ${file}: ${e.message}`);
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export function extractSummary(fileLines, line) {
32
+ if (!fileLines || !line || line <= 1) return null;
33
+ const idx = line - 2; // line above the definition (0-indexed)
34
+ // Scan up to 10 lines above for JSDoc or comment
35
+ let jsdocEnd = -1;
36
+ for (let i = idx; i >= Math.max(0, idx - 10); i--) {
37
+ const trimmed = fileLines[i].trim();
38
+ if (trimmed.endsWith('*/')) {
39
+ jsdocEnd = i;
40
+ break;
41
+ }
42
+ if (trimmed.startsWith('//') || trimmed.startsWith('#')) {
43
+ // Single-line comment immediately above
44
+ const text = trimmed
45
+ .replace(/^\/\/\s*/, '')
46
+ .replace(/^#\s*/, '')
47
+ .trim();
48
+ return text.length > 100 ? `${text.slice(0, 100)}...` : text;
49
+ }
50
+ if (trimmed !== '' && !trimmed.startsWith('*') && !trimmed.startsWith('/*')) break;
51
+ }
52
+ if (jsdocEnd >= 0) {
53
+ // Find opening /**
54
+ for (let i = jsdocEnd; i >= Math.max(0, jsdocEnd - 20); i--) {
55
+ if (fileLines[i].trim().startsWith('/**')) {
56
+ // Extract first non-tag, non-empty line
57
+ for (let j = i + 1; j <= jsdocEnd; j++) {
58
+ const docLine = fileLines[j]
59
+ .trim()
60
+ .replace(/^\*\s?/, '')
61
+ .trim();
62
+ if (docLine && !docLine.startsWith('@') && docLine !== '/' && docLine !== '*/') {
63
+ return docLine.length > 100 ? `${docLine.slice(0, 100)}...` : docLine;
64
+ }
65
+ }
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ export function extractSignature(fileLines, line) {
74
+ if (!fileLines || !line) return null;
75
+ const idx = line - 1;
76
+ // Gather up to 5 lines to handle multi-line params
77
+ const chunk = fileLines.slice(idx, Math.min(fileLines.length, idx + 5)).join('\n');
78
+
79
+ // JS/TS: function name(params) or (params) => or async function
80
+ let m = chunk.match(
81
+ /(?:export\s+)?(?:async\s+)?function\s*\*?\s*\w*\s*\(([^)]*)\)\s*(?::\s*([^\n{]+))?/,
82
+ );
83
+ if (m) {
84
+ return {
85
+ params: m[1].trim() || null,
86
+ returnType: m[2] ? m[2].trim().replace(/\s*\{$/, '') : null,
87
+ };
88
+ }
89
+ // Arrow: const name = (params) => or (params):ReturnType =>
90
+ m = chunk.match(/=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>\n{]+))?\s*=>/);
91
+ if (m) {
92
+ return {
93
+ params: m[1].trim() || null,
94
+ returnType: m[2] ? m[2].trim() : null,
95
+ };
96
+ }
97
+ // Python: def name(params) -> return:
98
+ m = chunk.match(/def\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^:\n]+))?/);
99
+ if (m) {
100
+ return {
101
+ params: m[1].trim() || null,
102
+ returnType: m[2] ? m[2].trim() : null,
103
+ };
104
+ }
105
+ // Go: func (recv) name(params) (returns)
106
+ m = chunk.match(/func\s+(?:\([^)]*\)\s+)?\w+\s*\(([^)]*)\)\s*(?:\(([^)]+)\)|(\w[^\n{]*))?/);
107
+ if (m) {
108
+ return {
109
+ params: m[1].trim() || null,
110
+ returnType: (m[2] || m[3] || '').trim() || null,
111
+ };
112
+ }
113
+ // Rust: fn name(params) -> ReturnType
114
+ m = chunk.match(/fn\s+\w+\s*\(([^)]*)\)\s*(?:->\s*([^\n{]+))?/);
115
+ if (m) {
116
+ return {
117
+ params: m[1].trim() || null,
118
+ returnType: m[2] ? m[2].trim() : null,
119
+ };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ export function createFileLinesReader(repoRoot) {
125
+ const cache = new Map();
126
+ return function getFileLines(file) {
127
+ if (cache.has(file)) return cache.get(file);
128
+ try {
129
+ const absPath = safePath(repoRoot, file);
130
+ if (!absPath) {
131
+ cache.set(file, null);
132
+ return null;
133
+ }
134
+ const lines = fs.readFileSync(absPath, 'utf-8').split('\n');
135
+ cache.set(file, lines);
136
+ return lines;
137
+ } catch (e) {
138
+ debug(`getFileLines failed for ${file}: ${e.message}`);
139
+ cache.set(file, null);
140
+ return null;
141
+ }
142
+ };
143
+ }
144
+
145
+ export function isFileLikeTarget(target) {
146
+ if (target.includes('/') || target.includes('\\')) return true;
147
+ const ext = path.extname(target).toLowerCase();
148
+ if (!ext) return false;
149
+ for (const entry of LANGUAGE_REGISTRY) {
150
+ if (entry.extensions.includes(ext)) return true;
151
+ }
152
+ return false;
153
+ }
@@ -0,0 +1,125 @@
1
+ import { iterateFunctionNodes, openReadonlyOrFail } from '../db.js';
2
+ import { isTestFile } from '../infrastructure/test-filter.js';
3
+ import { ALL_SYMBOL_KINDS } from '../kinds.js';
4
+
5
+ /**
6
+ * Generator: stream functions one-by-one using .iterate() for memory efficiency.
7
+ * @param {string} [customDbPath]
8
+ * @param {object} [opts]
9
+ * @param {boolean} [opts.noTests]
10
+ * @param {string} [opts.file]
11
+ * @param {string} [opts.pattern]
12
+ * @yields {{ name: string, kind: string, file: string, line: number, role: string|null }}
13
+ */
14
+ export function* iterListFunctions(customDbPath, opts = {}) {
15
+ const db = openReadonlyOrFail(customDbPath);
16
+ try {
17
+ const noTests = opts.noTests || false;
18
+
19
+ for (const row of iterateFunctionNodes(db, { file: opts.file, pattern: opts.pattern })) {
20
+ if (noTests && isTestFile(row.file)) continue;
21
+ yield {
22
+ name: row.name,
23
+ kind: row.kind,
24
+ file: row.file,
25
+ line: row.line,
26
+ endLine: row.end_line ?? null,
27
+ role: row.role ?? null,
28
+ };
29
+ }
30
+ } finally {
31
+ db.close();
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Generator: stream role-classified symbols one-by-one.
37
+ * @param {string} [customDbPath]
38
+ * @param {object} [opts]
39
+ * @param {boolean} [opts.noTests]
40
+ * @param {string} [opts.role]
41
+ * @param {string} [opts.file]
42
+ * @yields {{ name: string, kind: string, file: string, line: number, endLine: number|null, role: string }}
43
+ */
44
+ export function* iterRoles(customDbPath, opts = {}) {
45
+ const db = openReadonlyOrFail(customDbPath);
46
+ try {
47
+ const noTests = opts.noTests || false;
48
+ const conditions = ['role IS NOT NULL'];
49
+ const params = [];
50
+
51
+ if (opts.role) {
52
+ conditions.push('role = ?');
53
+ params.push(opts.role);
54
+ }
55
+ if (opts.file) {
56
+ conditions.push('file LIKE ?');
57
+ params.push(`%${opts.file}%`);
58
+ }
59
+
60
+ const stmt = db.prepare(
61
+ `SELECT name, kind, file, line, end_line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
62
+ );
63
+ for (const row of stmt.iterate(...params)) {
64
+ if (noTests && isTestFile(row.file)) continue;
65
+ yield {
66
+ name: row.name,
67
+ kind: row.kind,
68
+ file: row.file,
69
+ line: row.line,
70
+ endLine: row.end_line ?? null,
71
+ role: row.role ?? null,
72
+ };
73
+ }
74
+ } finally {
75
+ db.close();
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Generator: stream symbol lookup results one-by-one.
81
+ * @param {string} target - Symbol name to search for (partial match)
82
+ * @param {string} [customDbPath]
83
+ * @param {object} [opts]
84
+ * @param {boolean} [opts.noTests]
85
+ * @yields {{ name: string, kind: string, file: string, line: number, role: string|null, exported: boolean, uses: object[] }}
86
+ */
87
+ export function* iterWhere(target, customDbPath, opts = {}) {
88
+ const db = openReadonlyOrFail(customDbPath);
89
+ try {
90
+ const noTests = opts.noTests || false;
91
+ const placeholders = ALL_SYMBOL_KINDS.map(() => '?').join(', ');
92
+ const stmt = db.prepare(
93
+ `SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
94
+ );
95
+ const crossFileCallersStmt = db.prepare(
96
+ `SELECT COUNT(*) as cnt FROM edges e JOIN nodes n ON e.source_id = n.id
97
+ WHERE e.target_id = ? AND e.kind = 'calls' AND n.file != ?`,
98
+ );
99
+ const usesStmt = db.prepare(
100
+ `SELECT n.name, n.file, n.line FROM edges e JOIN nodes n ON e.source_id = n.id
101
+ WHERE e.target_id = ? AND e.kind = 'calls'`,
102
+ );
103
+ for (const node of stmt.iterate(`%${target}%`, ...ALL_SYMBOL_KINDS)) {
104
+ if (noTests && isTestFile(node.file)) continue;
105
+
106
+ const crossFileCallers = crossFileCallersStmt.get(node.id, node.file);
107
+ const exported = crossFileCallers.cnt > 0;
108
+
109
+ let uses = usesStmt.all(node.id);
110
+ if (noTests) uses = uses.filter((u) => !isTestFile(u.file));
111
+
112
+ yield {
113
+ name: node.name,
114
+ kind: node.kind,
115
+ file: node.file,
116
+ line: node.line,
117
+ role: node.role || null,
118
+ exported,
119
+ uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
120
+ };
121
+ }
122
+ } finally {
123
+ db.close();
124
+ }
125
+ }
@@ -0,0 +1,27 @@
1
+ import { getClassHierarchy } from '../db.js';
2
+
3
+ export function resolveMethodViaHierarchy(db, methodName) {
4
+ const methods = db
5
+ .prepare(`SELECT * FROM nodes WHERE kind = 'method' AND name LIKE ?`)
6
+ .all(`%.${methodName}`);
7
+
8
+ const results = [...methods];
9
+ for (const m of methods) {
10
+ const className = m.name.split('.')[0];
11
+ const classNode = db
12
+ .prepare(`SELECT * FROM nodes WHERE name = ? AND kind = 'class' AND file = ?`)
13
+ .get(className, m.file);
14
+ if (!classNode) continue;
15
+
16
+ const ancestors = getClassHierarchy(db, classNode.id);
17
+ for (const ancestorId of ancestors) {
18
+ const ancestor = db.prepare('SELECT name FROM nodes WHERE id = ?').get(ancestorId);
19
+ if (!ancestor) continue;
20
+ const parentMethods = db
21
+ .prepare(`SELECT * FROM nodes WHERE name = ? AND kind = 'method'`)
22
+ .all(`${ancestor.name}.${methodName}`);
23
+ results.push(...parentMethods);
24
+ }
25
+ }
26
+ return results;
27
+ }
@@ -0,0 +1,59 @@
1
+ export function getFileHash(db, file) {
2
+ const row = db.prepare('SELECT hash FROM file_hashes WHERE file = ?').get(file);
3
+ return row ? row.hash : null;
4
+ }
5
+
6
+ export function kindIcon(kind) {
7
+ switch (kind) {
8
+ case 'function':
9
+ return 'f';
10
+ case 'class':
11
+ return '*';
12
+ case 'method':
13
+ return 'o';
14
+ case 'file':
15
+ return '#';
16
+ case 'interface':
17
+ return 'I';
18
+ case 'type':
19
+ return 'T';
20
+ case 'parameter':
21
+ return 'p';
22
+ case 'property':
23
+ return '.';
24
+ case 'constant':
25
+ return 'C';
26
+ default:
27
+ return '-';
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Normalize a raw DB/query row into the stable 7-field symbol shape.
33
+ * @param {object} row - Raw row (from SELECT * or explicit columns)
34
+ * @param {object} [db] - Open DB handle; when null, fileHash will be null
35
+ * @param {Map} [hashCache] - Optional per-file cache to avoid repeated getFileHash calls
36
+ * @returns {{ name: string, kind: string, file: string, line: number, endLine: number|null, role: string|null, fileHash: string|null }}
37
+ */
38
+ export function normalizeSymbol(row, db, hashCache) {
39
+ let fileHash = null;
40
+ if (db) {
41
+ if (hashCache) {
42
+ if (!hashCache.has(row.file)) {
43
+ hashCache.set(row.file, getFileHash(db, row.file));
44
+ }
45
+ fileHash = hashCache.get(row.file);
46
+ } else {
47
+ fileHash = getFileHash(db, row.file);
48
+ }
49
+ }
50
+ return {
51
+ name: row.name,
52
+ kind: row.kind,
53
+ file: row.file,
54
+ line: row.line,
55
+ endLine: row.end_line ?? row.endLine ?? null,
56
+ role: row.role ?? null,
57
+ fileHash,
58
+ };
59
+ }
package/src/snapshot.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import Database from 'better-sqlite3';
4
4
  import { findDbPath } from './db.js';
5
+ import { ConfigError, DbError } from './errors.js';
5
6
  import { debug } from './logger.js';
6
7
 
7
8
  const NAME_RE = /^[a-zA-Z0-9_-]+$/;
@@ -12,7 +13,7 @@ const NAME_RE = /^[a-zA-Z0-9_-]+$/;
12
13
  */
13
14
  export function validateSnapshotName(name) {
14
15
  if (!name || !NAME_RE.test(name)) {
15
- throw new Error(
16
+ throw new ConfigError(
16
17
  `Invalid snapshot name "${name}". Use only letters, digits, hyphens, and underscores.`,
17
18
  );
18
19
  }
@@ -39,7 +40,7 @@ export function snapshotSave(name, options = {}) {
39
40
  validateSnapshotName(name);
40
41
  const dbPath = options.dbPath || findDbPath();
41
42
  if (!fs.existsSync(dbPath)) {
42
- throw new Error(`Database not found: ${dbPath}`);
43
+ throw new DbError(`Database not found: ${dbPath}`, { file: dbPath });
43
44
  }
44
45
 
45
46
  const dir = snapshotsDir(dbPath);
@@ -47,7 +48,7 @@ export function snapshotSave(name, options = {}) {
47
48
 
48
49
  if (fs.existsSync(dest)) {
49
50
  if (!options.force) {
50
- throw new Error(`Snapshot "${name}" already exists. Use --force to overwrite.`);
51
+ throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
51
52
  }
52
53
  fs.unlinkSync(dest);
53
54
  debug(`Deleted existing snapshot: ${dest}`);
@@ -82,7 +83,7 @@ export function snapshotRestore(name, options = {}) {
82
83
  const src = path.join(dir, `${name}.db`);
83
84
 
84
85
  if (!fs.existsSync(src)) {
85
- throw new Error(`Snapshot "${name}" not found at ${src}`);
86
+ throw new DbError(`Snapshot "${name}" not found at ${src}`, { file: src });
86
87
  }
87
88
 
88
89
  // Remove WAL/SHM sidecar files for a clean restore
@@ -141,7 +142,7 @@ export function snapshotDelete(name, options = {}) {
141
142
  const target = path.join(dir, `${name}.db`);
142
143
 
143
144
  if (!fs.existsSync(target)) {
144
- throw new Error(`Snapshot "${name}" not found at ${target}`);
145
+ throw new DbError(`Snapshot "${name}" not found at ${target}`, { file: target });
145
146
  }
146
147
 
147
148
  fs.unlinkSync(target);
package/src/structure.js CHANGED
@@ -312,13 +312,10 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
312
312
 
313
313
  // ─── Node role classification ─────────────────────────────────────────
314
314
 
315
- export const FRAMEWORK_ENTRY_PREFIXES = ['route:', 'event:', 'command:'];
315
+ // Re-export from classifier for backward compatibility
316
+ export { FRAMEWORK_ENTRY_PREFIXES } from './graph/classifiers/roles.js';
316
317
 
317
- function median(sorted) {
318
- if (sorted.length === 0) return 0;
319
- const mid = Math.floor(sorted.length / 2);
320
- return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
321
- }
318
+ import { classifyRoles } from './graph/classifiers/roles.js';
322
319
 
323
320
  export function classifyNodeRoles(db) {
324
321
  const rows = db
@@ -354,44 +351,22 @@ export function classifyNodeRoles(db) {
354
351
  .map((r) => r.target_id),
355
352
  );
356
353
 
357
- const nonZeroFanIn = rows
358
- .filter((r) => r.fan_in > 0)
359
- .map((r) => r.fan_in)
360
- .sort((a, b) => a - b);
361
- const nonZeroFanOut = rows
362
- .filter((r) => r.fan_out > 0)
363
- .map((r) => r.fan_out)
364
- .sort((a, b) => a - b);
354
+ // Delegate classification to the pure-logic classifier
355
+ const classifierInput = rows.map((r) => ({
356
+ id: String(r.id),
357
+ name: r.name,
358
+ fanIn: r.fan_in,
359
+ fanOut: r.fan_out,
360
+ isExported: exportedIds.has(r.id),
361
+ }));
365
362
 
366
- const medFanIn = median(nonZeroFanIn);
367
- const medFanOut = median(nonZeroFanOut);
363
+ const roleMap = classifyRoles(classifierInput);
368
364
 
369
- const updates = [];
365
+ // Build summary and updates
370
366
  const summary = { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, leaf: 0 };
371
-
367
+ const updates = [];
372
368
  for (const row of rows) {
373
- const highIn = row.fan_in >= medFanIn && row.fan_in > 0;
374
- const highOut = row.fan_out >= medFanOut && row.fan_out > 0;
375
- const isExported = exportedIds.has(row.id);
376
-
377
- let role;
378
- const isFrameworkEntry = FRAMEWORK_ENTRY_PREFIXES.some((p) => row.name.startsWith(p));
379
- if (isFrameworkEntry) {
380
- role = 'entry';
381
- } else if (row.fan_in === 0 && !isExported) {
382
- role = 'dead';
383
- } else if (row.fan_in === 0 && isExported) {
384
- role = 'entry';
385
- } else if (highIn && !highOut) {
386
- role = 'core';
387
- } else if (highIn && highOut) {
388
- role = 'utility';
389
- } else if (!highIn && highOut) {
390
- role = 'adapter';
391
- } else {
392
- role = 'leaf';
393
- }
394
-
369
+ const role = roleMap.get(String(row.id)) || 'leaf';
395
370
  updates.push({ id: row.id, role });
396
371
  summary[role]++;
397
372
  }
package/src/triage.js CHANGED
@@ -1,40 +1,9 @@
1
1
  import { findNodesForTriage, openReadonlyOrFail } from './db.js';
2
+ import { DEFAULT_WEIGHTS, scoreRisk } from './graph/classifiers/risk.js';
2
3
  import { isTestFile } from './infrastructure/test-filter.js';
3
4
  import { warn } from './logger.js';
4
5
  import { paginateResult } from './paginate.js';
5
6
 
6
- // ─── Constants ────────────────────────────────────────────────────────
7
-
8
- const DEFAULT_WEIGHTS = {
9
- fanIn: 0.25,
10
- complexity: 0.3,
11
- churn: 0.2,
12
- role: 0.15,
13
- mi: 0.1,
14
- };
15
-
16
- const ROLE_WEIGHTS = {
17
- core: 1.0,
18
- utility: 0.9,
19
- entry: 0.8,
20
- adapter: 0.5,
21
- leaf: 0.2,
22
- dead: 0.1,
23
- };
24
-
25
- const DEFAULT_ROLE_WEIGHT = 0.5;
26
-
27
- // ─── Helpers ──────────────────────────────────────────────────────────
28
-
29
- /** Min-max normalize an array of numbers. All-equal → all zeros. */
30
- function minMaxNormalize(values) {
31
- const min = Math.min(...values);
32
- const max = Math.max(...values);
33
- if (max === min) return values.map(() => 0);
34
- const range = max - min;
35
- return values.map((v) => (v - min) / range);
36
- }
37
-
38
7
  // ─── Data Function ────────────────────────────────────────────────────
39
8
 
40
9
  /**
@@ -81,48 +50,27 @@ export function triageData(customDbPath, opts = {}) {
81
50
  };
82
51
  }
83
52
 
84
- // Extract raw signal arrays
85
- const fanIns = filtered.map((r) => r.fan_in);
86
- const cognitives = filtered.map((r) => r.cognitive);
87
- const churns = filtered.map((r) => r.churn);
88
- const mis = filtered.map((r) => r.mi);
89
-
90
- // Min-max normalize
91
- const normFanIns = minMaxNormalize(fanIns);
92
- const normCognitives = minMaxNormalize(cognitives);
93
- const normChurns = minMaxNormalize(churns);
94
- // MI: higher is better, so invert: 1 - norm(mi)
95
- const normMIsRaw = minMaxNormalize(mis);
96
- const normMIs = normMIsRaw.map((v) => round4(1 - v));
53
+ // Delegate scoring to classifier
54
+ const riskMetrics = scoreRisk(filtered, weights);
97
55
 
98
56
  // Compute risk scores
99
- const items = filtered.map((r, i) => {
100
- const roleWeight = ROLE_WEIGHTS[r.role] ?? DEFAULT_ROLE_WEIGHT;
101
- const riskScore =
102
- weights.fanIn * normFanIns[i] +
103
- weights.complexity * normCognitives[i] +
104
- weights.churn * normChurns[i] +
105
- weights.role * roleWeight +
106
- weights.mi * normMIs[i];
107
-
108
- return {
109
- name: r.name,
110
- kind: r.kind,
111
- file: r.file,
112
- line: r.line,
113
- role: r.role || null,
114
- fanIn: r.fan_in,
115
- cognitive: r.cognitive,
116
- churn: r.churn,
117
- maintainabilityIndex: r.mi,
118
- normFanIn: round4(normFanIns[i]),
119
- normComplexity: round4(normCognitives[i]),
120
- normChurn: round4(normChurns[i]),
121
- normMI: round4(normMIs[i]),
122
- roleWeight,
123
- riskScore: round4(riskScore),
124
- };
125
- });
57
+ const items = filtered.map((r, i) => ({
58
+ name: r.name,
59
+ kind: r.kind,
60
+ file: r.file,
61
+ line: r.line,
62
+ role: r.role || null,
63
+ fanIn: r.fan_in,
64
+ cognitive: r.cognitive,
65
+ churn: r.churn,
66
+ maintainabilityIndex: r.mi,
67
+ normFanIn: riskMetrics[i].normFanIn,
68
+ normComplexity: riskMetrics[i].normComplexity,
69
+ normChurn: riskMetrics[i].normChurn,
70
+ normMI: riskMetrics[i].normMI,
71
+ roleWeight: riskMetrics[i].roleWeight,
72
+ riskScore: riskMetrics[i].riskScore,
73
+ }));
126
74
 
127
75
  // Apply minScore filter
128
76
  const scored = minScore != null ? items.filter((it) => it.riskScore >= minScore) : items;