@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/native.js CHANGED
@@ -8,9 +8,11 @@
8
8
 
9
9
  import { createRequire } from 'node:module';
10
10
  import os from 'node:os';
11
+ import { EngineError } from './errors.js';
11
12
 
12
13
  let _cached; // undefined = not yet tried, null = failed, object = module
13
14
  let _loadError = null;
15
+ const _require = createRequire(import.meta.url);
14
16
 
15
17
  /**
16
18
  * Detect whether the current Linux environment uses glibc or musl.
@@ -18,7 +20,7 @@ let _loadError = null;
18
20
  */
19
21
  function detectLibc() {
20
22
  try {
21
- const { readdirSync } = require('node:fs');
23
+ const { readdirSync } = _require('node:fs');
22
24
  const files = readdirSync('/lib');
23
25
  if (files.some((f) => f.startsWith('ld-musl-') && f.endsWith('.so.1'))) {
24
26
  return 'musl';
@@ -38,6 +40,17 @@ const PLATFORM_PACKAGES = {
38
40
  'win32-x64': '@optave/codegraph-win32-x64-msvc',
39
41
  };
40
42
 
43
+ /**
44
+ * Resolve the platform-specific npm package name for the native addon.
45
+ * Returns null if the current platform is not supported.
46
+ */
47
+ function resolvePlatformPackage() {
48
+ const platform = os.platform();
49
+ const arch = os.arch();
50
+ const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`;
51
+ return PLATFORM_PACKAGES[key] || null;
52
+ }
53
+
41
54
  /**
42
55
  * Try to load the native napi addon.
43
56
  * Returns the module on success, null on failure.
@@ -45,21 +58,16 @@ const PLATFORM_PACKAGES = {
45
58
  export function loadNative() {
46
59
  if (_cached !== undefined) return _cached;
47
60
 
48
- const require = createRequire(import.meta.url);
49
-
50
- const platform = os.platform();
51
- const arch = os.arch();
52
- const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`;
53
- const pkg = PLATFORM_PACKAGES[key];
61
+ const pkg = resolvePlatformPackage();
54
62
  if (pkg) {
55
63
  try {
56
- _cached = require(pkg);
64
+ _cached = _require(pkg);
57
65
  return _cached;
58
66
  } catch (err) {
59
67
  _loadError = err;
60
68
  }
61
69
  } else {
62
- _loadError = new Error(`Unsupported platform: ${key}`);
70
+ _loadError = new Error(`Unsupported platform: ${os.platform()}-${os.arch()}`);
63
71
  }
64
72
 
65
73
  _cached = null;
@@ -73,15 +81,31 @@ export function isNativeAvailable() {
73
81
  return loadNative() !== null;
74
82
  }
75
83
 
84
+ /**
85
+ * Read the version from the platform-specific npm package.json.
86
+ * Returns null if the package is not installed or has no version.
87
+ */
88
+ export function getNativePackageVersion() {
89
+ const pkg = resolvePlatformPackage();
90
+ if (!pkg) return null;
91
+ try {
92
+ const pkgJson = _require(`${pkg}/package.json`);
93
+ return pkgJson.version || null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
76
99
  /**
77
100
  * Return the native module or throw if not available.
78
101
  */
79
102
  export function getNative() {
80
103
  const mod = loadNative();
81
104
  if (!mod) {
82
- throw new Error(
105
+ throw new EngineError(
83
106
  `Native codegraph-core not available: ${_loadError?.message || 'unknown error'}. ` +
84
107
  'Install the platform package or use --engine wasm.',
108
+ { cause: _loadError },
85
109
  );
86
110
  }
87
111
  return mod;
package/src/parser.js CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { Language, Parser, Query } from 'web-tree-sitter';
5
5
  import { warn } from './logger.js';
6
- import { getNative, loadNative } from './native.js';
6
+ import { getNative, getNativePackageVersion, loadNative } from './native.js';
7
7
 
8
8
  // Re-export all extractors for backward compatibility
9
9
  export {
@@ -41,6 +41,9 @@ let _initialized = false;
41
41
  // Memoized parsers — avoids reloading WASM grammars on every createParsers() call
42
42
  let _cachedParsers = null;
43
43
 
44
+ // Cached Language objects — WASM-backed, must be .delete()'d explicitly
45
+ let _cachedLanguages = null;
46
+
44
47
  // Query cache for JS/TS/TSX extractors (populated during createParsers)
45
48
  const _queryCache = new Map();
46
49
 
@@ -77,12 +80,14 @@ export async function createParsers() {
77
80
  }
78
81
 
79
82
  const parsers = new Map();
83
+ const languages = new Map();
80
84
  for (const entry of LANGUAGE_REGISTRY) {
81
85
  try {
82
86
  const lang = await Language.load(grammarPath(entry.grammarFile));
83
87
  const parser = new Parser();
84
88
  parser.setLanguage(lang);
85
89
  parsers.set(entry.id, parser);
90
+ languages.set(entry.id, lang);
86
91
  // Compile and cache tree-sitter Query for JS/TS/TSX extractors
87
92
  if (entry.extractor === extractSymbols && !_queryCache.has(entry.id)) {
88
93
  const isTS = entry.id === 'typescript' || entry.id === 'tsx';
@@ -100,9 +105,47 @@ export async function createParsers() {
100
105
  }
101
106
  }
102
107
  _cachedParsers = parsers;
108
+ _cachedLanguages = languages;
103
109
  return parsers;
104
110
  }
105
111
 
112
+ /**
113
+ * Dispose all cached WASM parsers and queries to free WASM linear memory.
114
+ * Call this between repeated builds in the same process (e.g. benchmarks)
115
+ * to prevent memory accumulation that can cause segfaults.
116
+ */
117
+ export function disposeParsers() {
118
+ if (_cachedParsers) {
119
+ for (const [, parser] of _cachedParsers) {
120
+ if (parser && typeof parser.delete === 'function') {
121
+ try {
122
+ parser.delete();
123
+ } catch {}
124
+ }
125
+ }
126
+ _cachedParsers = null;
127
+ }
128
+ for (const [, query] of _queryCache) {
129
+ if (query && typeof query.delete === 'function') {
130
+ try {
131
+ query.delete();
132
+ } catch {}
133
+ }
134
+ }
135
+ _queryCache.clear();
136
+ if (_cachedLanguages) {
137
+ for (const [, lang] of _cachedLanguages) {
138
+ if (lang && typeof lang.delete === 'function') {
139
+ try {
140
+ lang.delete();
141
+ } catch {}
142
+ }
143
+ }
144
+ _cachedLanguages = null;
145
+ }
146
+ _initialized = false;
147
+ }
148
+
106
149
  export function getParser(parsers, filePath) {
107
150
  const ext = path.extname(filePath);
108
151
  const entry = _extToLang.get(ext);
@@ -214,6 +257,7 @@ function patchNativeResult(r) {
214
257
  if (i.csharpUsing === undefined) i.csharpUsing = i.csharp_using;
215
258
  if (i.rubyRequire === undefined) i.rubyRequire = i.ruby_require;
216
259
  if (i.phpUse === undefined) i.phpUse = i.php_use;
260
+ if (i.dynamicImport === undefined) i.dynamicImport = i.dynamic_import;
217
261
  }
218
262
  }
219
263
 
@@ -429,11 +473,18 @@ export async function parseFilesAuto(filePaths, rootDir, opts = {}) {
429
473
  */
430
474
  export function getActiveEngine(opts = {}) {
431
475
  const { name, native } = resolveEngine(opts);
432
- const version = native
476
+ let version = native
433
477
  ? typeof native.engineVersion === 'function'
434
478
  ? native.engineVersion()
435
479
  : null
436
480
  : null;
481
+ // Prefer platform package.json version over binary-embedded version
482
+ // to handle stale binaries that weren't recompiled during a release
483
+ if (native) {
484
+ try {
485
+ version = getNativePackageVersion() ?? version;
486
+ } catch {}
487
+ }
437
488
  return { name, version };
438
489
  }
439
490
 
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared color constants for the graph viewer.
3
+ *
4
+ * These live in a standalone module so both the domain layer (src/viewer.js)
5
+ * and the presentation layer (src/presentation/viewer.js) can import them
6
+ * without creating a cross-layer dependency.
7
+ */
8
+
9
+ export const DEFAULT_NODE_COLORS = {
10
+ function: '#4CAF50',
11
+ method: '#66BB6A',
12
+ class: '#2196F3',
13
+ interface: '#42A5F5',
14
+ type: '#7E57C2',
15
+ struct: '#FF7043',
16
+ enum: '#FFA726',
17
+ trait: '#26A69A',
18
+ record: '#EC407A',
19
+ module: '#78909C',
20
+ file: '#90A4AE',
21
+ };
22
+
23
+ export const DEFAULT_ROLE_COLORS = {
24
+ entry: '#e8f5e9',
25
+ core: '#e3f2fd',
26
+ utility: '#f5f5f5',
27
+ dead: '#ffebee',
28
+ leaf: '#fffde7',
29
+ };
30
+
31
+ export const COMMUNITY_COLORS = [
32
+ '#4CAF50',
33
+ '#2196F3',
34
+ '#FF9800',
35
+ '#9C27B0',
36
+ '#F44336',
37
+ '#00BCD4',
38
+ '#CDDC39',
39
+ '#E91E63',
40
+ '#3F51B5',
41
+ '#FF5722',
42
+ '#009688',
43
+ '#795548',
44
+ ];
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Graph export serializers — pure data → formatted string transforms.
3
+ *
4
+ * Each function receives pre-loaded graph data and returns a formatted string
5
+ * (or structured object for CSV). No DB access — all data must be pre-loaded.
6
+ */
7
+
8
+ import path from 'node:path';
9
+
10
+ // ─── Escape Helpers ──────────────────────────────────────────────────
11
+
12
+ /** Escape special XML characters. */
13
+ export function escapeXml(s) {
14
+ return String(s)
15
+ .replace(/&/g, '&')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;')
19
+ .replace(/'/g, '&apos;');
20
+ }
21
+
22
+ /** RFC 4180 CSV field escaping — quote fields containing commas, quotes, or newlines. */
23
+ export function escapeCsv(s) {
24
+ const str = String(s);
25
+ if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
26
+ return `"${str.replace(/"/g, '""')}"`;
27
+ }
28
+ return str;
29
+ }
30
+
31
+ /** Escape double quotes for Mermaid labels. */
32
+ export function escapeLabel(label) {
33
+ return label.replace(/"/g, '#quot;');
34
+ }
35
+
36
+ /** Map node kind to Mermaid shape wrapper. */
37
+ export function mermaidShape(kind, label) {
38
+ const escaped = escapeLabel(label);
39
+ switch (kind) {
40
+ case 'function':
41
+ case 'method':
42
+ return `(["${escaped}"])`;
43
+ case 'class':
44
+ case 'interface':
45
+ case 'type':
46
+ case 'struct':
47
+ case 'enum':
48
+ case 'trait':
49
+ case 'record':
50
+ return `{{"${escaped}"}}`;
51
+ case 'module':
52
+ return `[["${escaped}"]]`;
53
+ default:
54
+ return `["${escaped}"]`;
55
+ }
56
+ }
57
+
58
+ /** Map node role to Mermaid style colors. */
59
+ export const ROLE_STYLES = {
60
+ entry: 'fill:#e8f5e9,stroke:#4caf50',
61
+ core: 'fill:#e3f2fd,stroke:#2196f3',
62
+ utility: 'fill:#f5f5f5,stroke:#9e9e9e',
63
+ dead: 'fill:#ffebee,stroke:#f44336',
64
+ leaf: 'fill:#fffde7,stroke:#fdd835',
65
+ };
66
+
67
+ // ─── DOT Serializer ──────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Render file-level graph data as DOT (Graphviz) format.
71
+ *
72
+ * @param {{ dirs: Array<{ name: string, files: Array<{ path: string, basename: string }>, cohesion: number|null }>, edges: Array<{ source: string, target: string }>, totalEdges: number, limit?: number }} data
73
+ * @returns {string}
74
+ */
75
+ export function renderFileLevelDOT(data) {
76
+ const lines = [
77
+ 'digraph codegraph {',
78
+ ' rankdir=LR;',
79
+ ' node [shape=box, fontname="monospace", fontsize=10];',
80
+ ' edge [color="#666666"];',
81
+ '',
82
+ ];
83
+
84
+ let clusterIdx = 0;
85
+ for (const dir of data.dirs) {
86
+ lines.push(` subgraph cluster_${clusterIdx++} {`);
87
+ const cohLabel = dir.cohesion !== null ? ` (cohesion: ${dir.cohesion.toFixed(2)})` : '';
88
+ lines.push(` label="${dir.name}${cohLabel}";`);
89
+ lines.push(` style=dashed;`);
90
+ lines.push(` color="#999999";`);
91
+ for (const f of dir.files) {
92
+ lines.push(` "${f.path}" [label="${f.basename}"];`);
93
+ }
94
+ lines.push(` }`);
95
+ lines.push('');
96
+ }
97
+
98
+ for (const { source, target } of data.edges) {
99
+ lines.push(` "${source}" -> "${target}";`);
100
+ }
101
+ if (data.limit && data.totalEdges > data.limit) {
102
+ lines.push(` // Truncated: showing ${data.edges.length} of ${data.totalEdges} edges`);
103
+ }
104
+
105
+ lines.push('}');
106
+ return lines.join('\n');
107
+ }
108
+
109
+ /**
110
+ * Render function-level graph data as DOT (Graphviz) format.
111
+ *
112
+ * @param {{ edges: Array<{ source_name: string, source_file: string, target_name: string, target_file: string }>, totalEdges: number, limit?: number }} data
113
+ * @returns {string}
114
+ */
115
+ export function renderFunctionLevelDOT(data) {
116
+ const lines = [
117
+ 'digraph codegraph {',
118
+ ' rankdir=LR;',
119
+ ' node [shape=box, fontname="monospace", fontsize=10];',
120
+ ' edge [color="#666666"];',
121
+ '',
122
+ ];
123
+
124
+ const emittedNodes = new Set();
125
+ for (const e of data.edges) {
126
+ const sId = `${e.source_file}:${e.source_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
127
+ const tId = `${e.target_file}:${e.target_name}`.replace(/[^a-zA-Z0-9_]/g, '_');
128
+ if (!emittedNodes.has(sId)) {
129
+ lines.push(` ${sId} [label="${e.source_name}\\n${path.basename(e.source_file)}"];`);
130
+ emittedNodes.add(sId);
131
+ }
132
+ if (!emittedNodes.has(tId)) {
133
+ lines.push(` ${tId} [label="${e.target_name}\\n${path.basename(e.target_file)}"];`);
134
+ emittedNodes.add(tId);
135
+ }
136
+ lines.push(` ${sId} -> ${tId};`);
137
+ }
138
+ if (data.limit && data.totalEdges > data.limit) {
139
+ lines.push(` // Truncated: showing ${data.edges.length} of ${data.totalEdges} edges`);
140
+ }
141
+
142
+ lines.push('}');
143
+ return lines.join('\n');
144
+ }
145
+
146
+ // ─── Mermaid Serializer ──────────────────────────────────────────────
147
+
148
+ /**
149
+ * Render file-level graph data as Mermaid flowchart format.
150
+ *
151
+ * @param {{ direction: string, dirs: Array<{ name: string, files: string[] }>, edges: Array<{ source: string, target: string, edge_kind: string }>, totalEdges: number, limit?: number }} data
152
+ * @returns {string}
153
+ */
154
+ export function renderFileLevelMermaid(data) {
155
+ const lines = [`flowchart ${data.direction || 'LR'}`];
156
+
157
+ let nodeCounter = 0;
158
+ const nodeIdMap = new Map();
159
+ function nodeId(key) {
160
+ if (!nodeIdMap.has(key)) nodeIdMap.set(key, `n${nodeCounter++}`);
161
+ return nodeIdMap.get(key);
162
+ }
163
+
164
+ // Emit subgraphs
165
+ for (const dir of data.dirs) {
166
+ const sgId = dir.name.replace(/[^a-zA-Z0-9]/g, '_');
167
+ lines.push(` subgraph ${sgId}["${escapeLabel(dir.name)}"]`);
168
+ for (const f of dir.files) {
169
+ const nId = nodeId(f);
170
+ lines.push(` ${nId}["${escapeLabel(path.basename(f))}"]`);
171
+ }
172
+ lines.push(' end');
173
+ }
174
+
175
+ // Deduplicate edges per source-target pair, collecting all distinct kinds
176
+ const edgeMap = new Map();
177
+ for (const { source, target, edge_kind } of data.edges) {
178
+ const key = `${source}|${target}`;
179
+ const label = edge_kind === 'imports-type' ? 'imports' : edge_kind;
180
+ if (!edgeMap.has(key)) edgeMap.set(key, { source, target, labels: new Set() });
181
+ edgeMap.get(key).labels.add(label);
182
+ }
183
+
184
+ for (const { source, target, labels } of edgeMap.values()) {
185
+ lines.push(` ${nodeId(source)} -->|${[...labels].join(', ')}| ${nodeId(target)}`);
186
+ }
187
+ if (data.limit && data.totalEdges > data.limit) {
188
+ lines.push(` %% Truncated: showing ${data.edges.length} of ${data.totalEdges} edges`);
189
+ }
190
+
191
+ return lines.join('\n');
192
+ }
193
+
194
+ /**
195
+ * Render function-level graph data as Mermaid flowchart format.
196
+ *
197
+ * @param {{ direction: string, edges: Array, roles: Map<string, string>, totalEdges: number, limit?: number }} data
198
+ * @returns {string}
199
+ */
200
+ export function renderFunctionLevelMermaid(data) {
201
+ const lines = [`flowchart ${data.direction || 'LR'}`];
202
+
203
+ let nodeCounter = 0;
204
+ const nodeIdMap = new Map();
205
+ function nodeId(key) {
206
+ if (!nodeIdMap.has(key)) nodeIdMap.set(key, `n${nodeCounter++}`);
207
+ return nodeIdMap.get(key);
208
+ }
209
+
210
+ // Group nodes by file for subgraphs
211
+ const fileNodes = new Map();
212
+ const nodeKinds = new Map();
213
+ for (const e of data.edges) {
214
+ const sKey = `${e.source_file}::${e.source_name}`;
215
+ const tKey = `${e.target_file}::${e.target_name}`;
216
+ nodeId(sKey);
217
+ nodeId(tKey);
218
+ nodeKinds.set(sKey, e.source_kind);
219
+ nodeKinds.set(tKey, e.target_kind);
220
+
221
+ if (!fileNodes.has(e.source_file)) fileNodes.set(e.source_file, new Map());
222
+ fileNodes.get(e.source_file).set(sKey, e.source_name);
223
+
224
+ if (!fileNodes.has(e.target_file)) fileNodes.set(e.target_file, new Map());
225
+ fileNodes.get(e.target_file).set(tKey, e.target_name);
226
+ }
227
+
228
+ // Emit subgraphs grouped by file
229
+ for (const [file, nodes] of [...fileNodes].sort((a, b) => a[0].localeCompare(b[0]))) {
230
+ const sgId = file.replace(/[^a-zA-Z0-9]/g, '_');
231
+ lines.push(` subgraph ${sgId}["${escapeLabel(file)}"]`);
232
+ for (const [key, name] of nodes) {
233
+ const kind = nodeKinds.get(key);
234
+ lines.push(` ${nodeId(key)}${mermaidShape(kind, name)}`);
235
+ }
236
+ lines.push(' end');
237
+ }
238
+
239
+ // Emit edges with labels
240
+ for (const e of data.edges) {
241
+ const sId = nodeId(`${e.source_file}::${e.source_name}`);
242
+ const tId = nodeId(`${e.target_file}::${e.target_name}`);
243
+ lines.push(` ${sId} -->|${e.edge_kind}| ${tId}`);
244
+ }
245
+ if (data.limit && data.totalEdges > data.limit) {
246
+ lines.push(` %% Truncated: showing ${data.edges.length} of ${data.totalEdges} edges`);
247
+ }
248
+
249
+ // Role styling
250
+ const roleStyles = [];
251
+ for (const [key, nid] of nodeIdMap) {
252
+ const role = data.roles?.get(key);
253
+ if (role && ROLE_STYLES[role]) {
254
+ roleStyles.push(` style ${nid} ${ROLE_STYLES[role]}`);
255
+ }
256
+ }
257
+ lines.push(...roleStyles);
258
+
259
+ return lines.join('\n');
260
+ }
261
+
262
+ // ─── GraphML Serializer ──────────────────────────────────────────────
263
+
264
+ /**
265
+ * Render file-level graph data as GraphML (XML) format.
266
+ *
267
+ * @param {{ edges: Array<{ source: string, target: string }> }} data
268
+ * @returns {string}
269
+ */
270
+ export function renderFileLevelGraphML(data) {
271
+ const lines = [
272
+ '<?xml version="1.0" encoding="UTF-8"?>',
273
+ '<graphml xmlns="http://graphml.graphstruct.net/graphml">',
274
+ ' <key id="d0" for="node" attr.name="name" attr.type="string"/>',
275
+ ' <key id="d1" for="node" attr.name="file" attr.type="string"/>',
276
+ ' <key id="d2" for="edge" attr.name="kind" attr.type="string"/>',
277
+ ' <graph id="codegraph" edgedefault="directed">',
278
+ ];
279
+
280
+ const files = new Set();
281
+ for (const { source, target } of data.edges) {
282
+ files.add(source);
283
+ files.add(target);
284
+ }
285
+
286
+ const fileIds = new Map();
287
+ let nIdx = 0;
288
+ for (const f of files) {
289
+ const id = `n${nIdx++}`;
290
+ fileIds.set(f, id);
291
+ lines.push(` <node id="${id}">`);
292
+ lines.push(` <data key="d0">${escapeXml(path.basename(f))}</data>`);
293
+ lines.push(` <data key="d1">${escapeXml(f)}</data>`);
294
+ lines.push(' </node>');
295
+ }
296
+
297
+ let eIdx = 0;
298
+ for (const { source, target } of data.edges) {
299
+ lines.push(
300
+ ` <edge id="e${eIdx++}" source="${fileIds.get(source)}" target="${fileIds.get(target)}">`,
301
+ );
302
+ lines.push(' <data key="d2">imports</data>');
303
+ lines.push(' </edge>');
304
+ }
305
+
306
+ lines.push(' </graph>');
307
+ lines.push('</graphml>');
308
+ return lines.join('\n');
309
+ }
310
+
311
+ /**
312
+ * Render function-level graph data as GraphML (XML) format.
313
+ *
314
+ * @param {{ edges: Array }} data
315
+ * @returns {string}
316
+ */
317
+ export function renderFunctionLevelGraphML(data) {
318
+ const lines = [
319
+ '<?xml version="1.0" encoding="UTF-8"?>',
320
+ '<graphml xmlns="http://graphml.graphstruct.net/graphml">',
321
+ ' <key id="d0" for="node" attr.name="name" attr.type="string"/>',
322
+ ' <key id="d1" for="node" attr.name="kind" attr.type="string"/>',
323
+ ' <key id="d2" for="node" attr.name="file" attr.type="string"/>',
324
+ ' <key id="d3" for="node" attr.name="line" attr.type="int"/>',
325
+ ' <key id="d4" for="node" attr.name="role" attr.type="string"/>',
326
+ ' <key id="d5" for="edge" attr.name="kind" attr.type="string"/>',
327
+ ' <key id="d6" for="edge" attr.name="confidence" attr.type="double"/>',
328
+ ' <graph id="codegraph" edgedefault="directed">',
329
+ ];
330
+
331
+ const emittedNodes = new Set();
332
+ function emitNode(id, name, kind, file, line, role) {
333
+ if (emittedNodes.has(id)) return;
334
+ emittedNodes.add(id);
335
+ lines.push(` <node id="n${id}">`);
336
+ lines.push(` <data key="d0">${escapeXml(name)}</data>`);
337
+ lines.push(` <data key="d1">${escapeXml(kind)}</data>`);
338
+ lines.push(` <data key="d2">${escapeXml(file)}</data>`);
339
+ lines.push(` <data key="d3">${line}</data>`);
340
+ if (role) lines.push(` <data key="d4">${escapeXml(role)}</data>`);
341
+ lines.push(' </node>');
342
+ }
343
+
344
+ let eIdx = 0;
345
+ for (const e of data.edges) {
346
+ emitNode(
347
+ e.source_id,
348
+ e.source_name,
349
+ e.source_kind,
350
+ e.source_file,
351
+ e.source_line,
352
+ e.source_role,
353
+ );
354
+ emitNode(
355
+ e.target_id,
356
+ e.target_name,
357
+ e.target_kind,
358
+ e.target_file,
359
+ e.target_line,
360
+ e.target_role,
361
+ );
362
+ lines.push(` <edge id="e${eIdx++}" source="n${e.source_id}" target="n${e.target_id}">`);
363
+ lines.push(` <data key="d5">${escapeXml(e.edge_kind)}</data>`);
364
+ lines.push(` <data key="d6">${e.confidence}</data>`);
365
+ lines.push(' </edge>');
366
+ }
367
+
368
+ lines.push(' </graph>');
369
+ lines.push('</graphml>');
370
+ return lines.join('\n');
371
+ }
372
+
373
+ // ─── Neo4j CSV Serializer ────────────────────────────────────────────
374
+
375
+ /**
376
+ * Render file-level graph data as Neo4j bulk-import CSV.
377
+ *
378
+ * @param {{ edges: Array<{ source: string, target: string, edge_kind: string, confidence: number }> }} data
379
+ * @returns {{ nodes: string, relationships: string }}
380
+ */
381
+ export function renderFileLevelNeo4jCSV(data) {
382
+ const files = new Map();
383
+ let idx = 0;
384
+ for (const { source, target } of data.edges) {
385
+ if (!files.has(source)) files.set(source, idx++);
386
+ if (!files.has(target)) files.set(target, idx++);
387
+ }
388
+
389
+ const nodeLines = ['nodeId:ID,name,file:string,:LABEL'];
390
+ for (const [file, id] of files) {
391
+ nodeLines.push(`${id},${escapeCsv(path.basename(file))},${escapeCsv(file)},File`);
392
+ }
393
+
394
+ const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
395
+ for (const e of data.edges) {
396
+ const edgeType = e.edge_kind.toUpperCase().replace(/-/g, '_');
397
+ relLines.push(`${files.get(e.source)},${files.get(e.target)},${edgeType},${e.confidence}`);
398
+ }
399
+
400
+ return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
401
+ }
402
+
403
+ /**
404
+ * Render function-level graph data as Neo4j bulk-import CSV.
405
+ *
406
+ * @param {{ edges: Array }} data
407
+ * @returns {{ nodes: string, relationships: string }}
408
+ */
409
+ export function renderFunctionLevelNeo4jCSV(data) {
410
+ const emitted = new Set();
411
+ const nodeLines = ['nodeId:ID,name,kind,file:string,line:int,role,:LABEL'];
412
+ function emitNode(id, name, kind, file, line, role) {
413
+ if (emitted.has(id)) return;
414
+ emitted.add(id);
415
+ const label = kind.charAt(0).toUpperCase() + kind.slice(1);
416
+ nodeLines.push(
417
+ `${id},${escapeCsv(name)},${escapeCsv(kind)},${escapeCsv(file)},${line},${escapeCsv(role || '')},${label}`,
418
+ );
419
+ }
420
+
421
+ const relLines = [':START_ID,:END_ID,:TYPE,confidence:float'];
422
+ for (const e of data.edges) {
423
+ emitNode(
424
+ e.source_id,
425
+ e.source_name,
426
+ e.source_kind,
427
+ e.source_file,
428
+ e.source_line,
429
+ e.source_role,
430
+ );
431
+ emitNode(
432
+ e.target_id,
433
+ e.target_name,
434
+ e.target_kind,
435
+ e.target_file,
436
+ e.target_line,
437
+ e.target_role,
438
+ );
439
+ const edgeType = e.edge_kind.toUpperCase().replace(/-/g, '_');
440
+ relLines.push(`${e.source_id},${e.target_id},${edgeType},${e.confidence}`);
441
+ }
442
+
443
+ return { nodes: nodeLines.join('\n'), relationships: relLines.join('\n') };
444
+ }