@optave/codegraph 3.1.3 → 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 (185) hide show
  1. package/README.md +17 -19
  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 -1485
  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 -1522
  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/cycles.js +30 -85
  76. package/src/dataflow.js +1 -2
  77. package/src/db/connection.js +4 -4
  78. package/src/db/migrations.js +41 -0
  79. package/src/db/query-builder.js +6 -5
  80. package/src/db/repository/base.js +201 -0
  81. package/src/db/repository/graph-read.js +5 -2
  82. package/src/db/repository/in-memory-repository.js +584 -0
  83. package/src/db/repository/index.js +5 -1
  84. package/src/db/repository/nodes.js +63 -4
  85. package/src/db/repository/sqlite-repository.js +219 -0
  86. package/src/db.js +5 -0
  87. package/src/embeddings/generator.js +163 -0
  88. package/src/embeddings/index.js +13 -0
  89. package/src/embeddings/models.js +218 -0
  90. package/src/embeddings/search/cli-formatter.js +151 -0
  91. package/src/embeddings/search/filters.js +46 -0
  92. package/src/embeddings/search/hybrid.js +121 -0
  93. package/src/embeddings/search/keyword.js +68 -0
  94. package/src/embeddings/search/prepare.js +66 -0
  95. package/src/embeddings/search/semantic.js +145 -0
  96. package/src/embeddings/stores/fts5.js +27 -0
  97. package/src/embeddings/stores/sqlite-blob.js +24 -0
  98. package/src/embeddings/strategies/source.js +14 -0
  99. package/src/embeddings/strategies/structured.js +43 -0
  100. package/src/embeddings/strategies/text-utils.js +43 -0
  101. package/src/errors.js +78 -0
  102. package/src/export.js +217 -520
  103. package/src/extractors/csharp.js +10 -2
  104. package/src/extractors/go.js +3 -1
  105. package/src/extractors/helpers.js +71 -0
  106. package/src/extractors/java.js +9 -2
  107. package/src/extractors/javascript.js +38 -1
  108. package/src/extractors/php.js +3 -1
  109. package/src/extractors/python.js +14 -3
  110. package/src/extractors/rust.js +3 -1
  111. package/src/graph/algorithms/bfs.js +49 -0
  112. package/src/graph/algorithms/centrality.js +16 -0
  113. package/src/graph/algorithms/index.js +5 -0
  114. package/src/graph/algorithms/louvain.js +26 -0
  115. package/src/graph/algorithms/shortest-path.js +41 -0
  116. package/src/graph/algorithms/tarjan.js +49 -0
  117. package/src/graph/builders/dependency.js +91 -0
  118. package/src/graph/builders/index.js +3 -0
  119. package/src/graph/builders/structure.js +40 -0
  120. package/src/graph/builders/temporal.js +33 -0
  121. package/src/graph/classifiers/index.js +2 -0
  122. package/src/graph/classifiers/risk.js +85 -0
  123. package/src/graph/classifiers/roles.js +64 -0
  124. package/src/graph/index.js +13 -0
  125. package/src/graph/model.js +230 -0
  126. package/src/index.js +33 -210
  127. package/src/infrastructure/result-formatter.js +2 -21
  128. package/src/mcp/index.js +2 -0
  129. package/src/mcp/middleware.js +26 -0
  130. package/src/mcp/server.js +128 -0
  131. package/src/mcp/tool-registry.js +801 -0
  132. package/src/mcp/tools/ast-query.js +14 -0
  133. package/src/mcp/tools/audit.js +21 -0
  134. package/src/mcp/tools/batch-query.js +11 -0
  135. package/src/mcp/tools/branch-compare.js +10 -0
  136. package/src/mcp/tools/cfg.js +21 -0
  137. package/src/mcp/tools/check.js +43 -0
  138. package/src/mcp/tools/co-changes.js +20 -0
  139. package/src/mcp/tools/code-owners.js +12 -0
  140. package/src/mcp/tools/communities.js +15 -0
  141. package/src/mcp/tools/complexity.js +18 -0
  142. package/src/mcp/tools/context.js +17 -0
  143. package/src/mcp/tools/dataflow.js +26 -0
  144. package/src/mcp/tools/diff-impact.js +24 -0
  145. package/src/mcp/tools/execution-flow.js +26 -0
  146. package/src/mcp/tools/export-graph.js +57 -0
  147. package/src/mcp/tools/file-deps.js +12 -0
  148. package/src/mcp/tools/file-exports.js +13 -0
  149. package/src/mcp/tools/find-cycles.js +15 -0
  150. package/src/mcp/tools/fn-impact.js +15 -0
  151. package/src/mcp/tools/impact-analysis.js +12 -0
  152. package/src/mcp/tools/index.js +71 -0
  153. package/src/mcp/tools/list-functions.js +14 -0
  154. package/src/mcp/tools/list-repos.js +11 -0
  155. package/src/mcp/tools/module-map.js +6 -0
  156. package/src/mcp/tools/node-roles.js +14 -0
  157. package/src/mcp/tools/path.js +12 -0
  158. package/src/mcp/tools/query.js +30 -0
  159. package/src/mcp/tools/semantic-search.js +65 -0
  160. package/src/mcp/tools/sequence.js +17 -0
  161. package/src/mcp/tools/structure.js +15 -0
  162. package/src/mcp/tools/symbol-children.js +14 -0
  163. package/src/mcp/tools/triage.js +35 -0
  164. package/src/mcp/tools/where.js +13 -0
  165. package/src/mcp.js +2 -1470
  166. package/src/native.js +3 -1
  167. package/src/presentation/colors.js +44 -0
  168. package/src/presentation/export.js +444 -0
  169. package/src/presentation/result-formatter.js +21 -0
  170. package/src/presentation/sequence-renderer.js +43 -0
  171. package/src/presentation/table.js +47 -0
  172. package/src/presentation/viewer.js +634 -0
  173. package/src/queries.js +35 -2276
  174. package/src/resolve.js +1 -1
  175. package/src/sequence.js +2 -38
  176. package/src/shared/file-utils.js +153 -0
  177. package/src/shared/generators.js +125 -0
  178. package/src/shared/hierarchy.js +27 -0
  179. package/src/shared/normalize.js +59 -0
  180. package/src/snapshot.js +6 -5
  181. package/src/structure.js +15 -40
  182. package/src/triage.js +20 -72
  183. package/src/viewer.js +35 -656
  184. package/src/watcher.js +8 -148
  185. package/src/embedder.js +0 -1097
package/src/export.js CHANGED
@@ -1,368 +1,254 @@
1
1
  import path from 'node:path';
2
2
  import { isTestFile } from './infrastructure/test-filter.js';
3
3
  import { paginateResult } from './paginate.js';
4
+ import {
5
+ renderFileLevelDOT,
6
+ renderFileLevelGraphML,
7
+ renderFileLevelMermaid,
8
+ renderFileLevelNeo4jCSV,
9
+ renderFunctionLevelDOT,
10
+ renderFunctionLevelGraphML,
11
+ renderFunctionLevelMermaid,
12
+ renderFunctionLevelNeo4jCSV,
13
+ } from './presentation/export.js';
4
14
 
5
15
  const DEFAULT_MIN_CONFIDENCE = 0.5;
6
16
 
7
- /** Escape special XML characters. */
8
- function escapeXml(s) {
9
- return String(s)
10
- .replace(/&/g, '&')
11
- .replace(/</g, '&lt;')
12
- .replace(/>/g, '&gt;')
13
- .replace(/"/g, '&quot;')
14
- .replace(/'/g, '&apos;');
15
- }
17
+ // ─── Shared data loaders ─────────────────────────────────────────────
16
18
 
17
- /** RFC 4180 CSV field escaping — quote fields containing commas, quotes, or newlines. */
18
- function escapeCsv(s) {
19
- const str = String(s);
20
- if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
21
- return `"${str.replace(/"/g, '""')}"`;
22
- }
23
- return str;
19
+ /**
20
+ * Load file-level edges from DB with filtering.
21
+ * @param {object} db
22
+ * @param {object} opts
23
+ * @param {boolean} [opts.includeKind] - Include edge_kind in SELECT DISTINCT
24
+ * @param {boolean} [opts.includeConfidence] - Include confidence (adds a column to DISTINCT — use only when needed)
25
+ * @returns {{ edges: Array, totalEdges: number }}
26
+ */
27
+ function loadFileLevelEdges(
28
+ db,
29
+ { noTests, minConfidence, limit, includeKind = false, includeConfidence = false },
30
+ ) {
31
+ const minConf = minConfidence ?? DEFAULT_MIN_CONFIDENCE;
32
+ const kindClause = includeKind ? ', e.kind AS edge_kind' : '';
33
+ const confidenceClause = includeConfidence ? ', e.confidence' : '';
34
+ let edges = db
35
+ .prepare(
36
+ `
37
+ SELECT DISTINCT n1.file AS source, n2.file AS target${kindClause}${confidenceClause}
38
+ FROM edges e
39
+ JOIN nodes n1 ON e.source_id = n1.id
40
+ JOIN nodes n2 ON e.target_id = n2.id
41
+ WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
42
+ AND e.confidence >= ?
43
+ `,
44
+ )
45
+ .all(minConf);
46
+ if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
47
+ const totalEdges = edges.length;
48
+ if (limit && edges.length > limit) edges = edges.slice(0, limit);
49
+ return { edges, totalEdges };
24
50
  }
25
51
 
26
52
  /**
27
- * Export the dependency graph in DOT (Graphviz) format.
53
+ * Load function-level edges from DB with filtering.
54
+ * Returns the maximal field set needed by any serializer.
55
+ * @returns {{ edges: Array, totalEdges: number }}
28
56
  */
29
- export function exportDOT(db, opts = {}) {
30
- const fileLevel = opts.fileLevel !== false;
31
- const noTests = opts.noTests || false;
32
- const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
33
- const edgeLimit = opts.limit;
34
- const lines = [
35
- 'digraph codegraph {',
36
- ' rankdir=LR;',
37
- ' node [shape=box, fontname="monospace", fontsize=10];',
38
- ' edge [color="#666666"];',
39
- '',
40
- ];
41
-
42
- if (fileLevel) {
43
- let edges = db
44
- .prepare(`
45
- SELECT DISTINCT n1.file AS source, n2.file AS target
57
+ function loadFunctionLevelEdges(db, { noTests, minConfidence, limit }) {
58
+ const minConf = minConfidence ?? DEFAULT_MIN_CONFIDENCE;
59
+ let edges = db
60
+ .prepare(
61
+ `
62
+ SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind,
63
+ n1.file AS source_file, n1.line AS source_line, n1.role AS source_role,
64
+ n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind,
65
+ n2.file AS target_file, n2.line AS target_line, n2.role AS target_role,
66
+ e.kind AS edge_kind, e.confidence
46
67
  FROM edges e
47
68
  JOIN nodes n1 ON e.source_id = n1.id
48
69
  JOIN nodes n2 ON e.target_id = n2.id
49
- WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
70
+ WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
71
+ AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
72
+ AND e.kind = 'calls'
50
73
  AND e.confidence >= ?
51
- `)
52
- .all(minConf);
53
- if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
54
- const totalFileEdges = edges.length;
55
- if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
74
+ `,
75
+ )
76
+ .all(minConf);
77
+ if (noTests)
78
+ edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
79
+ const totalEdges = edges.length;
80
+ if (limit && edges.length > limit) edges = edges.slice(0, limit);
81
+ return { edges, totalEdges };
82
+ }
56
83
 
57
- // Try to use directory nodes from DB (built by structure analysis)
58
- const hasDirectoryNodes =
59
- db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'directory'").get().c > 0;
84
+ /**
85
+ * Load directory groupings for file-level graphs.
86
+ * Uses DB directory nodes if available, falls back to path.dirname().
87
+ * @returns {Array<{ name: string, files: Array<{ path: string, basename: string }>, cohesion: number|null }>}
88
+ */
89
+ function loadDirectoryGroups(db, allFiles) {
90
+ const hasDirectoryNodes =
91
+ db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'directory'").get().c > 0;
60
92
 
61
- const dirs = new Map();
62
- const allFiles = new Set();
63
- for (const { source, target } of edges) {
64
- allFiles.add(source);
65
- allFiles.add(target);
66
- }
93
+ const dirs = new Map();
67
94
 
68
- if (hasDirectoryNodes) {
69
- // Use DB directory structure with cohesion labels
70
- const dbDirs = db
95
+ if (hasDirectoryNodes) {
96
+ const dbDirs = db
97
+ .prepare(`
98
+ SELECT n.id, n.name, nm.cohesion
99
+ FROM nodes n
100
+ LEFT JOIN node_metrics nm ON n.id = nm.node_id
101
+ WHERE n.kind = 'directory'
102
+ `)
103
+ .all();
104
+
105
+ for (const d of dbDirs) {
106
+ const containedFiles = db
71
107
  .prepare(`
72
- SELECT n.id, n.name, nm.cohesion
73
- FROM nodes n
74
- LEFT JOIN node_metrics nm ON n.id = nm.node_id
75
- WHERE n.kind = 'directory'
108
+ SELECT n.name FROM edges e
109
+ JOIN nodes n ON e.target_id = n.id
110
+ WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
76
111
  `)
77
- .all();
78
-
79
- for (const d of dbDirs) {
80
- const containedFiles = db
81
- .prepare(`
82
- SELECT n.name FROM edges e
83
- JOIN nodes n ON e.target_id = n.id
84
- WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
85
- `)
86
- .all(d.id)
87
- .map((r) => r.name)
88
- .filter((f) => allFiles.has(f));
89
-
90
- if (containedFiles.length > 0) {
91
- dirs.set(d.name, { files: containedFiles, cohesion: d.cohesion });
92
- }
93
- }
94
- } else {
95
- // Fallback: reconstruct from path.dirname()
96
- for (const file of allFiles) {
97
- const dir = path.dirname(file) || '.';
98
- if (!dirs.has(dir)) dirs.set(dir, { files: [], cohesion: null });
99
- dirs.get(dir).files.push(file);
100
- }
101
- }
112
+ .all(d.id)
113
+ .map((r) => r.name)
114
+ .filter((f) => allFiles.has(f));
102
115
 
103
- let clusterIdx = 0;
104
- for (const [dir, info] of [...dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
105
- lines.push(` subgraph cluster_${clusterIdx++} {`);
106
- const cohLabel = info.cohesion !== null ? ` (cohesion: ${info.cohesion.toFixed(2)})` : '';
107
- lines.push(` label="${dir}${cohLabel}";`);
108
- lines.push(` style=dashed;`);
109
- lines.push(` color="#999999";`);
110
- for (const f of info.files) {
111
- const label = path.basename(f);
112
- lines.push(` "${f}" [label="${label}"];`);
116
+ if (containedFiles.length > 0) {
117
+ dirs.set(d.name, { files: containedFiles, cohesion: d.cohesion ?? null });
113
118
  }
114
- lines.push(` }`);
115
- lines.push('');
116
- }
117
-
118
- for (const { source, target } of edges) {
119
- lines.push(` "${source}" -> "${target}";`);
120
- }
121
- if (edgeLimit && totalFileEdges > edgeLimit) {
122
- lines.push(` // Truncated: showing ${edges.length} of ${totalFileEdges} edges`);
123
119
  }
124
120
  } else {
125
- let edges = db
126
- .prepare(`
127
- SELECT n1.name AS source_name, n1.kind AS source_kind, n1.file AS source_file,
128
- n2.name AS target_name, n2.kind AS target_kind, n2.file AS target_file,
129
- e.kind AS edge_kind
130
- FROM edges e
131
- JOIN nodes n1 ON e.source_id = n1.id
132
- JOIN nodes n2 ON e.target_id = n2.id
133
- WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module') AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
134
- AND e.kind = 'calls'
135
- AND e.confidence >= ?
136
- `)
137
- .all(minConf);
138
- if (noTests)
139
- edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
140
- const totalFnEdges = edges.length;
141
- if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
142
-
143
- for (const e of edges) {
144
- const sId = `${e.source_file}:${e.source_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
145
- const tId = `${e.target_file}:${e.target_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
146
- lines.push(` ${sId} [label="${e.source_name}\\n${path.basename(e.source_file)}"];`);
147
- lines.push(` ${tId} [label="${e.target_name}\\n${path.basename(e.target_file)}"];`);
148
- lines.push(` ${sId} -> ${tId};`);
149
- }
150
- if (edgeLimit && totalFnEdges > edgeLimit) {
151
- lines.push(` // Truncated: showing ${edges.length} of ${totalFnEdges} edges`);
121
+ for (const file of allFiles) {
122
+ const dir = path.dirname(file) || '.';
123
+ if (!dirs.has(dir)) dirs.set(dir, { files: [], cohesion: null });
124
+ dirs.get(dir).files.push(file);
152
125
  }
153
126
  }
154
127
 
155
- lines.push('}');
156
- return lines.join('\n');
128
+ return [...dirs]
129
+ .sort((a, b) => a[0].localeCompare(b[0]))
130
+ .map(([name, info]) => ({
131
+ name,
132
+ files: info.files.map((f) => ({ path: f, basename: path.basename(f) })),
133
+ cohesion: info.cohesion,
134
+ }));
157
135
  }
158
136
 
159
- /** Escape double quotes for Mermaid labels. */
160
- function escapeLabel(label) {
161
- return label.replace(/"/g, '#quot;');
137
+ /**
138
+ * Load directory groupings for Mermaid file-level graphs (simplified — no cohesion, string arrays).
139
+ */
140
+ function loadMermaidDirectoryGroups(db, allFiles) {
141
+ const hasDirectoryNodes =
142
+ db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'directory'").get().c > 0;
143
+
144
+ const dirs = new Map();
145
+
146
+ if (hasDirectoryNodes) {
147
+ const dbDirs = db.prepare("SELECT id, name FROM nodes WHERE kind = 'directory'").all();
148
+ for (const d of dbDirs) {
149
+ const containedFiles = db
150
+ .prepare(`
151
+ SELECT n.name FROM edges e
152
+ JOIN nodes n ON e.target_id = n.id
153
+ WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
154
+ `)
155
+ .all(d.id)
156
+ .map((r) => r.name)
157
+ .filter((f) => allFiles.has(f));
158
+ if (containedFiles.length > 0) dirs.set(d.name, containedFiles);
159
+ }
160
+ } else {
161
+ for (const file of allFiles) {
162
+ const dir = path.dirname(file) || '.';
163
+ if (!dirs.has(dir)) dirs.set(dir, []);
164
+ dirs.get(dir).push(file);
165
+ }
166
+ }
167
+
168
+ return [...dirs]
169
+ .sort((a, b) => a[0].localeCompare(b[0]))
170
+ .map(([name, files]) => ({ name, files }));
162
171
  }
163
172
 
164
- /** Map node kind to Mermaid shape wrapper. */
165
- function mermaidShape(kind, label) {
166
- const escaped = escapeLabel(label);
167
- switch (kind) {
168
- case 'function':
169
- case 'method':
170
- return `(["${escaped}"])`;
171
- case 'class':
172
- case 'interface':
173
- case 'type':
174
- case 'struct':
175
- case 'enum':
176
- case 'trait':
177
- case 'record':
178
- return `{{"${escaped}"}}`;
179
- case 'module':
180
- return `[["${escaped}"]]`;
181
- default:
182
- return `["${escaped}"]`;
173
+ /**
174
+ * Load node roles for Mermaid function-level styling.
175
+ * @returns {Map<string, string>} "file::name" → role
176
+ */
177
+ function loadNodeRoles(db, edges) {
178
+ const roles = new Map();
179
+ const seen = new Set();
180
+ for (const e of edges) {
181
+ for (const [file, name] of [
182
+ [e.source_file, e.source_name],
183
+ [e.target_file, e.target_name],
184
+ ]) {
185
+ const key = `${file}::${name}`;
186
+ if (seen.has(key)) continue;
187
+ seen.add(key);
188
+ const row = db
189
+ .prepare('SELECT role FROM nodes WHERE file = ? AND name = ? AND role IS NOT NULL LIMIT 1')
190
+ .get(file, name);
191
+ if (row?.role) roles.set(key, row.role);
192
+ }
183
193
  }
194
+ return roles;
184
195
  }
185
196
 
186
- /** Map node role to Mermaid style colors. */
187
- const ROLE_STYLES = {
188
- entry: 'fill:#e8f5e9,stroke:#4caf50',
189
- core: 'fill:#e3f2fd,stroke:#2196f3',
190
- utility: 'fill:#f5f5f5,stroke:#9e9e9e',
191
- dead: 'fill:#ffebee,stroke:#f44336',
192
- leaf: 'fill:#fffde7,stroke:#fdd835',
193
- };
197
+ // ─── Public API ──────────────────────────────────────────────────────
194
198
 
195
199
  /**
196
- * Export the dependency graph in Mermaid format.
200
+ * Export the dependency graph in DOT (Graphviz) format.
197
201
  */
198
- export function exportMermaid(db, opts = {}) {
202
+ export function exportDOT(db, opts = {}) {
199
203
  const fileLevel = opts.fileLevel !== false;
200
204
  const noTests = opts.noTests || false;
201
- const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
202
- const direction = opts.direction || 'LR';
203
- const edgeLimit = opts.limit;
204
- const lines = [`flowchart ${direction}`];
205
-
206
- let nodeCounter = 0;
207
- const nodeIdMap = new Map();
208
- function nodeId(key) {
209
- if (!nodeIdMap.has(key)) nodeIdMap.set(key, `n${nodeCounter++}`);
210
- return nodeIdMap.get(key);
211
- }
205
+ const minConfidence = opts.minConfidence;
206
+ const limit = opts.limit;
212
207
 
213
208
  if (fileLevel) {
214
- let edges = db
215
- .prepare(`
216
- SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind AS edge_kind
217
- FROM edges e
218
- JOIN nodes n1 ON e.source_id = n1.id
219
- JOIN nodes n2 ON e.target_id = n2.id
220
- WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
221
- AND e.confidence >= ?
222
- `)
223
- .all(minConf);
224
- if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
225
- const totalMermaidFileEdges = edges.length;
226
- if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
227
-
228
- // Collect all files referenced in edges
209
+ const { edges, totalEdges } = loadFileLevelEdges(db, { noTests, minConfidence, limit });
229
210
  const allFiles = new Set();
230
211
  for (const { source, target } of edges) {
231
212
  allFiles.add(source);
232
213
  allFiles.add(target);
233
214
  }
215
+ const dirs = loadDirectoryGroups(db, allFiles);
216
+ return renderFileLevelDOT({ dirs, edges, totalEdges, limit });
217
+ }
234
218
 
235
- // Build directory groupings try DB directory nodes first, fall back to path.dirname()
236
- const dirs = new Map();
237
- const hasDirectoryNodes =
238
- db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'directory'").get().c > 0;
239
-
240
- if (hasDirectoryNodes) {
241
- const dbDirs = db.prepare("SELECT id, name FROM nodes WHERE kind = 'directory'").all();
242
- for (const d of dbDirs) {
243
- const containedFiles = db
244
- .prepare(`
245
- SELECT n.name FROM edges e
246
- JOIN nodes n ON e.target_id = n.id
247
- WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
248
- `)
249
- .all(d.id)
250
- .map((r) => r.name)
251
- .filter((f) => allFiles.has(f));
252
- if (containedFiles.length > 0) dirs.set(d.name, containedFiles);
253
- }
254
- } else {
255
- for (const file of allFiles) {
256
- const dir = path.dirname(file) || '.';
257
- if (!dirs.has(dir)) dirs.set(dir, []);
258
- dirs.get(dir).push(file);
259
- }
260
- }
261
-
262
- // Emit subgraphs
263
- for (const [dir, files] of [...dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
264
- const sgId = dir.replace(/[^a-zA-Z0-9]/g, '_');
265
- lines.push(` subgraph ${sgId}["${escapeLabel(dir)}"]`);
266
- for (const f of files) {
267
- const nId = nodeId(f);
268
- lines.push(` ${nId}["${escapeLabel(path.basename(f))}"]`);
269
- }
270
- lines.push(' end');
271
- }
272
-
273
- // Deduplicate edges per source-target pair, collecting all distinct kinds
274
- const edgeMap = new Map();
275
- for (const { source, target, edge_kind } of edges) {
276
- const key = `${source}|${target}`;
277
- const label = edge_kind === 'imports-type' ? 'imports' : edge_kind;
278
- if (!edgeMap.has(key)) edgeMap.set(key, { source, target, labels: new Set() });
279
- edgeMap.get(key).labels.add(label);
280
- }
281
-
282
- for (const { source, target, labels } of edgeMap.values()) {
283
- lines.push(` ${nodeId(source)} -->|${[...labels].join(', ')}| ${nodeId(target)}`);
284
- }
285
- if (edgeLimit && totalMermaidFileEdges > edgeLimit) {
286
- lines.push(` %% Truncated: showing ${edges.length} of ${totalMermaidFileEdges} edges`);
287
- }
288
- } else {
289
- let edges = db
290
- .prepare(`
291
- SELECT n1.name AS source_name, n1.kind AS source_kind, n1.file AS source_file,
292
- n2.name AS target_name, n2.kind AS target_kind, n2.file AS target_file,
293
- e.kind AS edge_kind
294
- FROM edges e
295
- JOIN nodes n1 ON e.source_id = n1.id
296
- JOIN nodes n2 ON e.target_id = n2.id
297
- WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
298
- AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
299
- AND e.kind = 'calls'
300
- AND e.confidence >= ?
301
- `)
302
- .all(minConf);
303
- if (noTests)
304
- edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
305
- const totalMermaidFnEdges = edges.length;
306
- if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
307
-
308
- // Group nodes by file for subgraphs
309
- const fileNodes = new Map();
310
- const nodeKinds = new Map();
311
- for (const e of edges) {
312
- const sKey = `${e.source_file}::${e.source_name}`;
313
- const tKey = `${e.target_file}::${e.target_name}`;
314
- nodeId(sKey);
315
- nodeId(tKey);
316
- nodeKinds.set(sKey, e.source_kind);
317
- nodeKinds.set(tKey, e.target_kind);
318
-
319
- if (!fileNodes.has(e.source_file)) fileNodes.set(e.source_file, new Map());
320
- fileNodes.get(e.source_file).set(sKey, e.source_name);
321
-
322
- if (!fileNodes.has(e.target_file)) fileNodes.set(e.target_file, new Map());
323
- fileNodes.get(e.target_file).set(tKey, e.target_name);
324
- }
325
-
326
- // Emit subgraphs grouped by file
327
- for (const [file, nodes] of [...fileNodes].sort((a, b) => a[0].localeCompare(b[0]))) {
328
- const sgId = file.replace(/[^a-zA-Z0-9]/g, '_');
329
- lines.push(` subgraph ${sgId}["${escapeLabel(file)}"]`);
330
- for (const [key, name] of nodes) {
331
- const kind = nodeKinds.get(key);
332
- lines.push(` ${nodeId(key)}${mermaidShape(kind, name)}`);
333
- }
334
- lines.push(' end');
335
- }
219
+ const { edges, totalEdges } = loadFunctionLevelEdges(db, { noTests, minConfidence, limit });
220
+ return renderFunctionLevelDOT({ edges, totalEdges, limit });
221
+ }
336
222
 
337
- // Emit edges with labels
338
- for (const e of edges) {
339
- const sId = nodeId(`${e.source_file}::${e.source_name}`);
340
- const tId = nodeId(`${e.target_file}::${e.target_name}`);
341
- lines.push(` ${sId} -->|${e.edge_kind}| ${tId}`);
342
- }
343
- if (edgeLimit && totalMermaidFnEdges > edgeLimit) {
344
- lines.push(` %% Truncated: showing ${edges.length} of ${totalMermaidFnEdges} edges`);
345
- }
223
+ /**
224
+ * Export the dependency graph in Mermaid format.
225
+ */
226
+ export function exportMermaid(db, opts = {}) {
227
+ const fileLevel = opts.fileLevel !== false;
228
+ const noTests = opts.noTests || false;
229
+ const minConfidence = opts.minConfidence;
230
+ const direction = opts.direction || 'LR';
231
+ const limit = opts.limit;
346
232
 
347
- // Role styling — query roles for all referenced nodes
348
- const allKeys = [...nodeIdMap.keys()];
349
- const roleStyles = [];
350
- for (const key of allKeys) {
351
- const colonIdx = key.indexOf('::');
352
- if (colonIdx === -1) continue;
353
- const file = key.slice(0, colonIdx);
354
- const name = key.slice(colonIdx + 2);
355
- const row = db
356
- .prepare('SELECT role FROM nodes WHERE file = ? AND name = ? AND role IS NOT NULL LIMIT 1')
357
- .get(file, name);
358
- if (row?.role && ROLE_STYLES[row.role]) {
359
- roleStyles.push(` style ${nodeIdMap.get(key)} ${ROLE_STYLES[row.role]}`);
360
- }
233
+ if (fileLevel) {
234
+ const { edges, totalEdges } = loadFileLevelEdges(db, {
235
+ noTests,
236
+ minConfidence,
237
+ limit,
238
+ includeKind: true,
239
+ });
240
+ const allFiles = new Set();
241
+ for (const { source, target } of edges) {
242
+ allFiles.add(source);
243
+ allFiles.add(target);
361
244
  }
362
- lines.push(...roleStyles);
245
+ const dirs = loadMermaidDirectoryGroups(db, allFiles);
246
+ return renderFileLevelMermaid({ direction, dirs, edges, totalEdges, limit });
363
247
  }
364
248
 
365
- return lines.join('\n');
249
+ const { edges, totalEdges } = loadFunctionLevelEdges(db, { noTests, minConfidence, limit });
250
+ const roles = loadNodeRoles(db, edges);
251
+ return renderFunctionLevelMermaid({ direction, edges, roles, totalEdges, limit });
366
252
  }
367
253
 
368
254
  /**
@@ -400,129 +286,16 @@ export function exportJSON(db, opts = {}) {
400
286
  export function exportGraphML(db, opts = {}) {
401
287
  const fileLevel = opts.fileLevel !== false;
402
288
  const noTests = opts.noTests || false;
403
- const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
404
- const edgeLimit = opts.limit;
405
-
406
- const lines = [
407
- '<?xml version="1.0" encoding="UTF-8"?>',
408
- '<graphml xmlns="http://graphml.graphstruct.net/graphml">',
409
- ];
289
+ const minConfidence = opts.minConfidence;
290
+ const limit = opts.limit;
410
291
 
411
292
  if (fileLevel) {
412
- lines.push(' <key id="d0" for="node" attr.name="name" attr.type="string"/>');
413
- lines.push(' <key id="d1" for="node" attr.name="file" attr.type="string"/>');
414
- lines.push(' <key id="d2" for="edge" attr.name="kind" attr.type="string"/>');
415
- lines.push(' <graph id="codegraph" edgedefault="directed">');
416
-
417
- let edges = db
418
- .prepare(`
419
- SELECT DISTINCT n1.file AS source, n2.file AS target
420
- FROM edges e
421
- JOIN nodes n1 ON e.source_id = n1.id
422
- JOIN nodes n2 ON e.target_id = n2.id
423
- WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
424
- AND e.confidence >= ?
425
- `)
426
- .all(minConf);
427
- if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
428
- if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
429
-
430
- const files = new Set();
431
- for (const { source, target } of edges) {
432
- files.add(source);
433
- files.add(target);
434
- }
435
-
436
- const fileIds = new Map();
437
- let nIdx = 0;
438
- for (const f of files) {
439
- const id = `n${nIdx++}`;
440
- fileIds.set(f, id);
441
- lines.push(` <node id="${id}">`);
442
- lines.push(` <data key="d0">${escapeXml(path.basename(f))}</data>`);
443
- lines.push(` <data key="d1">${escapeXml(f)}</data>`);
444
- lines.push(' </node>');
445
- }
446
-
447
- let eIdx = 0;
448
- for (const { source, target } of edges) {
449
- lines.push(
450
- ` <edge id="e${eIdx++}" source="${fileIds.get(source)}" target="${fileIds.get(target)}">`,
451
- );
452
- lines.push(' <data key="d2">imports</data>');
453
- lines.push(' </edge>');
454
- }
455
- } else {
456
- lines.push(' <key id="d0" for="node" attr.name="name" attr.type="string"/>');
457
- lines.push(' <key id="d1" for="node" attr.name="kind" attr.type="string"/>');
458
- lines.push(' <key id="d2" for="node" attr.name="file" attr.type="string"/>');
459
- lines.push(' <key id="d3" for="node" attr.name="line" attr.type="int"/>');
460
- lines.push(' <key id="d4" for="node" attr.name="role" attr.type="string"/>');
461
- lines.push(' <key id="d5" for="edge" attr.name="kind" attr.type="string"/>');
462
- lines.push(' <key id="d6" for="edge" attr.name="confidence" attr.type="double"/>');
463
- lines.push(' <graph id="codegraph" edgedefault="directed">');
464
-
465
- let edges = db
466
- .prepare(`
467
- SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind,
468
- n1.file AS source_file, n1.line AS source_line, n1.role AS source_role,
469
- n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind,
470
- n2.file AS target_file, n2.line AS target_line, n2.role AS target_role,
471
- e.kind AS edge_kind, e.confidence
472
- FROM edges e
473
- JOIN nodes n1 ON e.source_id = n1.id
474
- JOIN nodes n2 ON e.target_id = n2.id
475
- WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
476
- AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
477
- AND e.kind = 'calls'
478
- AND e.confidence >= ?
479
- `)
480
- .all(minConf);
481
- if (noTests)
482
- edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
483
- if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
484
-
485
- const emittedNodes = new Set();
486
- function emitNode(id, name, kind, file, line, role) {
487
- if (emittedNodes.has(id)) return;
488
- emittedNodes.add(id);
489
- lines.push(` <node id="n${id}">`);
490
- lines.push(` <data key="d0">${escapeXml(name)}</data>`);
491
- lines.push(` <data key="d1">${escapeXml(kind)}</data>`);
492
- lines.push(` <data key="d2">${escapeXml(file)}</data>`);
493
- lines.push(` <data key="d3">${line}</data>`);
494
- if (role) lines.push(` <data key="d4">${escapeXml(role)}</data>`);
495
- lines.push(' </node>');
496
- }
497
-
498
- let eIdx = 0;
499
- for (const e of edges) {
500
- emitNode(
501
- e.source_id,
502
- e.source_name,
503
- e.source_kind,
504
- e.source_file,
505
- e.source_line,
506
- e.source_role,
507
- );
508
- emitNode(
509
- e.target_id,
510
- e.target_name,
511
- e.target_kind,
512
- e.target_file,
513
- e.target_line,
514
- e.target_role,
515
- );
516
- lines.push(` <edge id="e${eIdx++}" source="n${e.source_id}" target="n${e.target_id}">`);
517
- lines.push(` <data key="d5">${escapeXml(e.edge_kind)}</data>`);
518
- lines.push(` <data key="d6">${e.confidence}</data>`);
519
- lines.push(' </edge>');
520
- }
293
+ const { edges } = loadFileLevelEdges(db, { noTests, minConfidence, limit });
294
+ return renderFileLevelGraphML({ edges });
521
295
  }
522
296
 
523
- lines.push(' </graph>');
524
- lines.push('</graphml>');
525
- return lines.join('\n');
297
+ const { edges } = loadFunctionLevelEdges(db, { noTests, minConfidence, limit });
298
+ return renderFunctionLevelGraphML({ edges });
526
299
  }
527
300
 
528
301
  /**
@@ -586,96 +359,20 @@ export function exportGraphSON(db, opts = {}) {
586
359
  export function exportNeo4jCSV(db, opts = {}) {
587
360
  const fileLevel = opts.fileLevel !== false;
588
361
  const noTests = opts.noTests || false;
589
- const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
590
- const edgeLimit = opts.limit;
362
+ const minConfidence = opts.minConfidence;
363
+ const limit = opts.limit;
591
364
 
592
365
  if (fileLevel) {
593
- let edges = db
594
- .prepare(`
595
- SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind, e.confidence
596
- FROM edges e
597
- JOIN nodes n1 ON e.source_id = n1.id
598
- JOIN nodes n2 ON e.target_id = n2.id
599
- WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
600
- AND e.confidence >= ?
601
- `)
602
- .all(minConf);
603
- if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
604
- if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
605
-
606
- const files = new Map();
607
- let idx = 0;
608
- for (const { source, target } of edges) {
609
- if (!files.has(source)) files.set(source, idx++);
610
- if (!files.has(target)) files.set(target, idx++);
611
- }
612
-
613
- const nodeLines = ['nodeId:ID,name,file:string,:LABEL'];
614
- for (const [file, id] of files) {
615
- nodeLines.push(`${id},${escapeCsv(path.basename(file))},${escapeCsv(file)},File`);
616
- }
617
-
618
- const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
619
- for (const e of edges) {
620
- const edgeType = e.kind.toUpperCase().replace(/-/g, '_');
621
- relLines.push(`${files.get(e.source)},${files.get(e.target)},${edgeType},${e.confidence}`);
622
- }
623
-
624
- return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
625
- }
626
-
627
- let edges = db
628
- .prepare(`
629
- SELECT n1.id AS source_id, n1.name AS source_name, n1.kind AS source_kind,
630
- n1.file AS source_file, n1.line AS source_line, n1.role AS source_role,
631
- n2.id AS target_id, n2.name AS target_name, n2.kind AS target_kind,
632
- n2.file AS target_file, n2.line AS target_line, n2.role AS target_role,
633
- e.kind AS edge_kind, e.confidence
634
- FROM edges e
635
- JOIN nodes n1 ON e.source_id = n1.id
636
- JOIN nodes n2 ON e.target_id = n2.id
637
- WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
638
- AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
639
- AND e.kind = 'calls'
640
- AND e.confidence >= ?
641
- `)
642
- .all(minConf);
643
- if (noTests)
644
- edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
645
- if (edgeLimit && edges.length > edgeLimit) edges = edges.slice(0, edgeLimit);
646
-
647
- const emitted = new Set();
648
- const nodeLines = ['nodeId:ID,name,kind,file:string,line:int,role,:LABEL'];
649
- function emitNode(id, name, kind, file, line, role) {
650
- if (emitted.has(id)) return;
651
- emitted.add(id);
652
- const label = kind.charAt(0).toUpperCase() + kind.slice(1);
653
- nodeLines.push(
654
- `${id},${escapeCsv(name)},${escapeCsv(kind)},${escapeCsv(file)},${line},${escapeCsv(role || '')},${label}`,
655
- );
656
- }
657
-
658
- const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
659
- for (const e of edges) {
660
- emitNode(
661
- e.source_id,
662
- e.source_name,
663
- e.source_kind,
664
- e.source_file,
665
- e.source_line,
666
- e.source_role,
667
- );
668
- emitNode(
669
- e.target_id,
670
- e.target_name,
671
- e.target_kind,
672
- e.target_file,
673
- e.target_line,
674
- e.target_role,
675
- );
676
- const edgeType = e.edge_kind.toUpperCase().replace(/-/g, '_');
677
- relLines.push(`${e.source_id},${e.target_id},${edgeType},${e.confidence}`);
366
+ const { edges } = loadFileLevelEdges(db, {
367
+ noTests,
368
+ minConfidence,
369
+ limit,
370
+ includeKind: true,
371
+ includeConfidence: true,
372
+ });
373
+ return renderFileLevelNeo4jCSV({ edges });
678
374
  }
679
375
 
680
- return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
376
+ const { edges } = loadFunctionLevelEdges(db, { noTests, minConfidence, limit });
377
+ return renderFunctionLevelNeo4jCSV({ edges });
681
378
  }