From c21c38786f47c2c9bcbc9a188c0bffd2160f7af0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 02:37:50 -0700 Subject: [PATCH 1/6] chore: configure bge-large as default embedding model Sets Xenova/bge-large-en-v1.5 (1024d) as the default embedding model for codegraph self-analysis, replacing the default minilm. --- .codegraphrc.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .codegraphrc.json diff --git a/.codegraphrc.json b/.codegraphrc.json new file mode 100644 index 00000000..98663830 --- /dev/null +++ b/.codegraphrc.json @@ -0,0 +1,3 @@ +{ + "embeddings": { "model": "bge-large" } +} From ae301c079a35efef4b8e76ae4201a8a6353dffbc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 09:18:23 -0700 Subject: [PATCH 2/6] feat: enhance Mermaid export with subgraphs, edge labels, node shapes and styling - Change `graph LR` to `flowchart {direction}` with configurable --direction option - Add directory subgraph clustering in file-level mode (ported from DOT export) - Add edge labels from edge.kind (imports, calls) with imports-type collapsed to imports - Add node shapes by kind: stadium for functions/methods, hexagon for classes/structs, subroutine for modules - Add role-based styling: entry=green, core=blue, utility=gray, dead=red, leaf=yellow - Group function-level nodes by file into subgraphs - Use stable node IDs (n0, n1, ...) for cleaner output Impact: 4 functions changed, 2 affected --- src/cli.js | 2 + src/export.js | 161 +++++++++++++++++++++++++++++++--- tests/graph/export.test.js | 128 ++++++++++++++++++++++++++- tests/integration/cli.test.js | 4 +- 4 files changed, 277 insertions(+), 18 deletions(-) diff --git a/src/cli.js b/src/cli.js index 60dbac63..b4c57db1 100644 --- a/src/cli.js +++ b/src/cli.js @@ -274,6 +274,7 @@ program .option('-T, --no-tests', 'Exclude test/spec files') .option('--include-tests', 'Include test/spec files (overrides excludeTests config)') .option('--min-confidence ', 'Minimum edge confidence threshold (default: 0.5)', '0.5') + .option('--direction ', 'Flowchart direction for Mermaid: TB, LR, RL, BT', 'LR') .option('-o, --output ', 'Write to file instead of stdout') .action((opts) => { const db = openReadonlyOrFail(opts.db); @@ -281,6 +282,7 @@ program fileLevel: !opts.functions, noTests: resolveNoTests(opts), minConfidence: parseFloat(opts.minConfidence), + direction: opts.direction, }; let output; diff --git a/src/export.js b/src/export.js index 55433ca0..4d2a245c 100644 --- a/src/export.js +++ b/src/export.js @@ -125,6 +125,36 @@ export function exportDOT(db, opts = {}) { return lines.join('\n'); } +/** Map node kind to Mermaid shape wrapper. */ +function mermaidShape(kind, label) { + switch (kind) { + case 'function': + case 'method': + return `(["${label}"])`; + case 'class': + case 'interface': + case 'type': + case 'struct': + case 'enum': + case 'trait': + case 'record': + return `{{"${label}"}}`; + case 'module': + return `[["${label}"]]`; + default: + return `["${label}"]`; + } +} + +/** Map node role to Mermaid style colors. */ +const ROLE_STYLES = { + entry: 'fill:#e8f5e9,stroke:#4caf50', + core: 'fill:#e3f2fd,stroke:#2196f3', + utility: 'fill:#f5f5f5,stroke:#9e9e9e', + dead: 'fill:#ffebee,stroke:#f44336', + leaf: 'fill:#fffde7,stroke:#fdd835', +}; + /** * Export the dependency graph in Mermaid format. */ @@ -132,12 +162,20 @@ export function exportMermaid(db, opts = {}) { const fileLevel = opts.fileLevel !== false; const noTests = opts.noTests || false; const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE; - const lines = ['graph LR']; + const direction = opts.direction || 'LR'; + const lines = [`flowchart ${direction}`]; + + let nodeCounter = 0; + const nodeIdMap = new Map(); + function nodeId(key) { + if (!nodeIdMap.has(key)) nodeIdMap.set(key, `n${nodeCounter++}`); + return nodeIdMap.get(key); + } if (fileLevel) { let edges = db .prepare(` - SELECT DISTINCT n1.file AS source, n2.file AS target + SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind AS edge_kind FROM edges e JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id @@ -147,32 +185,129 @@ export function exportMermaid(db, opts = {}) { .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target)); + // Collect all files referenced in edges + const allFiles = new Set(); for (const { source, target } of edges) { - const s = source.replace(/[^a-zA-Z0-9]/g, '_'); - const t = target.replace(/[^a-zA-Z0-9]/g, '_'); - lines.push(` ${s}["${source}"] --> ${t}["${target}"]`); + allFiles.add(source); + allFiles.add(target); + } + + // Build directory groupings — try DB directory nodes first, fall back to path.dirname() + const dirs = new Map(); + const hasDirectoryNodes = + db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'directory'").get().c > 0; + + if (hasDirectoryNodes) { + const dbDirs = db.prepare("SELECT id, name FROM nodes WHERE kind = 'directory'").all(); + for (const d of dbDirs) { + const containedFiles = db + .prepare(` + SELECT n.name FROM edges e + JOIN nodes n ON e.target_id = n.id + WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file' + `) + .all(d.id) + .map((r) => r.name) + .filter((f) => allFiles.has(f)); + if (containedFiles.length > 0) dirs.set(d.name, containedFiles); + } + } else { + for (const file of allFiles) { + const dir = path.dirname(file) || '.'; + if (!dirs.has(dir)) dirs.set(dir, []); + dirs.get(dir).push(file); + } + } + + // Emit subgraphs + for (const [dir, files] of [...dirs].sort((a, b) => a[0].localeCompare(b[0]))) { + const sgId = dir.replace(/[^a-zA-Z0-9]/g, '_'); + lines.push(` subgraph ${sgId}["${dir}"]`); + for (const f of files) { + const nId = nodeId(f); + lines.push(` ${nId}["${path.basename(f)}"]`); + } + lines.push(' end'); + } + + // Deduplicate edges per source-target pair, picking the most specific kind + const edgeMap = new Map(); + for (const { source, target, edge_kind } of edges) { + const key = `${source}|${target}`; + const label = edge_kind === 'imports-type' ? 'imports' : edge_kind; + if (!edgeMap.has(key)) edgeMap.set(key, { source, target, label }); + } + + for (const { source, target, label } of edgeMap.values()) { + lines.push(` ${nodeId(source)} -->|${label}| ${nodeId(target)}`); } } else { let edges = db .prepare(` - SELECT n1.name AS source_name, n1.file AS source_file, - n2.name AS target_name, n2.file AS target_file + SELECT n1.name AS source_name, n1.kind AS source_kind, n1.file AS source_file, + n2.name AS target_name, n2.kind AS target_kind, n2.file AS target_file, + e.kind AS edge_kind FROM edges e JOIN nodes n1 ON e.source_id = n1.id JOIN nodes n2 ON e.target_id = n2.id - 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') - AND e.kind = 'calls' - AND e.confidence >= ? + 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') + AND e.kind = 'calls' + AND e.confidence >= ? `) .all(minConf); if (noTests) edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file)); + // Group nodes by file for subgraphs + const fileNodes = new Map(); + const nodeKinds = new Map(); + for (const e of edges) { + const sKey = `${e.source_file}::${e.source_name}`; + const tKey = `${e.target_file}::${e.target_name}`; + nodeId(sKey); + nodeId(tKey); + nodeKinds.set(sKey, e.source_kind); + nodeKinds.set(tKey, e.target_kind); + + if (!fileNodes.has(e.source_file)) fileNodes.set(e.source_file, new Map()); + fileNodes.get(e.source_file).set(sKey, e.source_name); + + if (!fileNodes.has(e.target_file)) fileNodes.set(e.target_file, new Map()); + fileNodes.get(e.target_file).set(tKey, e.target_name); + } + + // Emit subgraphs grouped by file + for (const [file, nodes] of [...fileNodes].sort((a, b) => a[0].localeCompare(b[0]))) { + const sgId = file.replace(/[^a-zA-Z0-9]/g, '_'); + lines.push(` subgraph ${sgId}["${file}"]`); + for (const [key, name] of nodes) { + const kind = nodeKinds.get(key); + lines.push(` ${nodeId(key)}${mermaidShape(kind, name)}`); + } + lines.push(' end'); + } + + // Emit edges with labels for (const e of edges) { - const sId = `${e.source_file}_${e.source_name}`.replace(/[^a-zA-Z0-9]/g, '_'); - const tId = `${e.target_file}_${e.target_name}`.replace(/[^a-zA-Z0-9]/g, '_'); - lines.push(` ${sId}["${e.source_name}"] --> ${tId}["${e.target_name}"]`); + const sId = nodeId(`${e.source_file}::${e.source_name}`); + const tId = nodeId(`${e.target_file}::${e.target_name}`); + lines.push(` ${sId} -->|${e.edge_kind}| ${tId}`); + } + + // Role styling — query roles for all referenced nodes + const allKeys = [...nodeIdMap.keys()]; + const roleStyles = []; + for (const key of allKeys) { + const [file, name] = key.split('::'); + const row = db + .prepare('SELECT role FROM nodes WHERE file = ? AND name = ? AND role IS NOT NULL LIMIT 1') + .get(file, name); + if (row?.role && ROLE_STYLES[row.role]) { + roleStyles.push(` style ${nodeIdMap.get(key)} ${ROLE_STYLES[row.role]}`); + } } + lines.push(...roleStyles); } return lines.join('\n'); diff --git a/tests/graph/export.test.js b/tests/graph/export.test.js index 909c1b74..ac89b91a 100644 --- a/tests/graph/export.test.js +++ b/tests/graph/export.test.js @@ -43,17 +43,65 @@ describe('exportDOT', () => { }); describe('exportMermaid', () => { - it('generates valid Mermaid syntax', () => { + it('generates valid Mermaid syntax with flowchart LR default', () => { const db = createTestDb(); const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); insertEdge(db, a, b, 'imports'); const mermaid = exportMermaid(db); - expect(mermaid).toContain('graph LR'); + expect(mermaid).toContain('flowchart LR'); expect(mermaid).toContain('-->'); db.close(); }); + + it('uses custom direction option', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const mermaid = exportMermaid(db, { direction: 'TB' }); + expect(mermaid).toContain('flowchart TB'); + db.close(); + }); + + it('groups files into directory subgraphs', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'lib/b.js', 'file', 'lib/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const mermaid = exportMermaid(db); + expect(mermaid).toContain('subgraph'); + expect(mermaid).toContain('"src"'); + expect(mermaid).toContain('"lib"'); + expect(mermaid).toContain('end'); + db.close(); + }); + + it('adds edge labels from edge kind', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports'); + + const mermaid = exportMermaid(db); + expect(mermaid).toContain('-->|imports|'); + db.close(); + }); + + it('collapses imports-type to imports label', () => { + const db = createTestDb(); + const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0); + const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0); + insertEdge(db, a, b, 'imports-type'); + + const mermaid = exportMermaid(db); + expect(mermaid).toContain('-->|imports|'); + expect(mermaid).not.toContain('imports-type'); + db.close(); + }); }); describe('exportDOT — function-level', () => { @@ -107,12 +155,86 @@ describe('exportMermaid — function-level', () => { insertEdge(db, fnA, fnB, 'calls'); const mermaid = exportMermaid(db, { fileLevel: false }); - expect(mermaid).toContain('graph LR'); + expect(mermaid).toContain('flowchart LR'); expect(mermaid).toContain('doWork'); expect(mermaid).toContain('helper'); expect(mermaid).toContain('-->'); db.close(); }); + + it('uses stadium shape for functions', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fnA, fnB, 'calls'); + + const mermaid = exportMermaid(db, { fileLevel: false }); + expect(mermaid).toContain('(["doWork"])'); + expect(mermaid).toContain('(["helper"])'); + db.close(); + }); + + it('uses hexagon shape for classes', () => { + const db = createTestDb(); + const cls = insertNode(db, 'MyClass', 'class', 'src/a.js', 5); + const fn = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, cls, fn, 'calls'); + + const mermaid = exportMermaid(db, { fileLevel: false }); + expect(mermaid).toContain('{{"MyClass"}}'); + db.close(); + }); + + it('uses subroutine shape for modules', () => { + const db = createTestDb(); + const mod = insertNode(db, 'MyModule', 'module', 'src/a.js', 5); + const fn = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, mod, fn, 'calls'); + + const mermaid = exportMermaid(db, { fileLevel: false }); + expect(mermaid).toContain('[["MyModule"]]'); + db.close(); + }); + + it('adds edge labels for calls', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fnA, fnB, 'calls'); + + const mermaid = exportMermaid(db, { fileLevel: false }); + expect(mermaid).toContain('-->|calls|'); + db.close(); + }); + + it('groups functions by file into subgraphs', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10); + insertEdge(db, fnA, fnB, 'calls'); + + const mermaid = exportMermaid(db, { fileLevel: false }); + expect(mermaid).toContain('subgraph'); + expect(mermaid).toContain('"src/a.js"'); + expect(mermaid).toContain('"src/b.js"'); + expect(mermaid).toContain('end'); + db.close(); + }); + + it('applies role styling', () => { + const db = createTestDb(); + const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5); + const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10); + // Add role to the nodes + db.prepare('UPDATE nodes SET role = ? WHERE id = ?').run('entry', fnA); + db.prepare('UPDATE nodes SET role = ? WHERE id = ?').run('utility', fnB); + insertEdge(db, fnA, fnB, 'calls'); + + const mermaid = exportMermaid(db, { fileLevel: false }); + expect(mermaid).toContain('fill:#e8f5e9,stroke:#4caf50'); + expect(mermaid).toContain('fill:#f5f5f5,stroke:#9e9e9e'); + db.close(); + }); }); describe('exportJSON', () => { diff --git a/tests/integration/cli.test.js b/tests/integration/cli.test.js index 40de2144..750200c5 100644 --- a/tests/integration/cli.test.js +++ b/tests/integration/cli.test.js @@ -130,9 +130,9 @@ describe('CLI smoke tests', () => { }); // ─── Export (Mermaid) ──────────────────────────────────────────────── - test('export -f mermaid outputs graph LR', () => { + test('export -f mermaid outputs flowchart LR', () => { const out = run('export', '--db', dbPath, '-f', 'mermaid'); - expect(out).toContain('graph LR'); + expect(out).toContain('flowchart LR'); }); // ─── Export (JSON) ─────────────────────────────────────────────────── From 1c4ca34804119a88c42903afa0ccaf78c4551bf9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 09:26:52 -0700 Subject: [PATCH 3/6] fix: escape quotes in Mermaid labels and fix misleading comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add escapeLabel() to replace double quotes with #quot; in all Mermaid labels (node shapes, file basenames, directory names, subgraph titles) - Fix misleading comment: "picking the most specific kind" → "keeping the first encountered kind" Impact: 3 functions changed, 2 affected --- src/export.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/export.js b/src/export.js index 4d2a245c..665114e4 100644 --- a/src/export.js +++ b/src/export.js @@ -125,12 +125,18 @@ export function exportDOT(db, opts = {}) { return lines.join('\n'); } +/** Escape double quotes for Mermaid labels. */ +function escapeLabel(label) { + return label.replace(/"/g, '#quot;'); +} + /** Map node kind to Mermaid shape wrapper. */ function mermaidShape(kind, label) { + const escaped = escapeLabel(label); switch (kind) { case 'function': case 'method': - return `(["${label}"])`; + return `(["${escaped}"])`; case 'class': case 'interface': case 'type': @@ -138,11 +144,11 @@ function mermaidShape(kind, label) { case 'enum': case 'trait': case 'record': - return `{{"${label}"}}`; + return `{{"${escaped}"}}`; case 'module': - return `[["${label}"]]`; + return `[["${escaped}"]]`; default: - return `["${label}"]`; + return `["${escaped}"]`; } } @@ -222,15 +228,15 @@ export function exportMermaid(db, opts = {}) { // Emit subgraphs for (const [dir, files] of [...dirs].sort((a, b) => a[0].localeCompare(b[0]))) { const sgId = dir.replace(/[^a-zA-Z0-9]/g, '_'); - lines.push(` subgraph ${sgId}["${dir}"]`); + lines.push(` subgraph ${sgId}["${escapeLabel(dir)}"]`); for (const f of files) { const nId = nodeId(f); - lines.push(` ${nId}["${path.basename(f)}"]`); + lines.push(` ${nId}["${escapeLabel(path.basename(f))}"]`); } lines.push(' end'); } - // Deduplicate edges per source-target pair, picking the most specific kind + // Deduplicate edges per source-target pair, keeping the first encountered kind const edgeMap = new Map(); for (const { source, target, edge_kind } of edges) { const key = `${source}|${target}`; @@ -280,7 +286,7 @@ export function exportMermaid(db, opts = {}) { // Emit subgraphs grouped by file for (const [file, nodes] of [...fileNodes].sort((a, b) => a[0].localeCompare(b[0]))) { const sgId = file.replace(/[^a-zA-Z0-9]/g, '_'); - lines.push(` subgraph ${sgId}["${file}"]`); + lines.push(` subgraph ${sgId}["${escapeLabel(file)}"]`); for (const [key, name] of nodes) { const kind = nodeKinds.get(key); lines.push(` ${nodeId(key)}${mermaidShape(kind, name)}`); From b9d6ae45510e52f0ba73c4603a98eb0a0064cc15 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 09:48:23 -0700 Subject: [PATCH 4/6] fix: use indexOf for :: split to handle paths with colons Impact: 1 functions changed, 0 affected --- src/export.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/export.js b/src/export.js index 665114e4..94c9760e 100644 --- a/src/export.js +++ b/src/export.js @@ -305,7 +305,9 @@ export function exportMermaid(db, opts = {}) { const allKeys = [...nodeIdMap.keys()]; const roleStyles = []; for (const key of allKeys) { - const [file, name] = key.split('::'); + const colonIdx = key.indexOf('::'); + const file = colonIdx !== -1 ? key.slice(0, colonIdx) : key; + const name = colonIdx !== -1 ? key.slice(colonIdx + 2) : ''; const row = db .prepare('SELECT role FROM nodes WHERE file = ? AND name = ? AND role IS NOT NULL LIMIT 1') .get(file, name); From 4f40eee4844939da0113e863243df831e10bcb17 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 10:06:44 -0700 Subject: [PATCH 5/6] fix: collect all distinct edge kinds per pair instead of keeping only first Impact: 1 functions changed, 0 affected --- src/export.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/export.js b/src/export.js index 94c9760e..d2b8f85c 100644 --- a/src/export.js +++ b/src/export.js @@ -236,16 +236,17 @@ export function exportMermaid(db, opts = {}) { lines.push(' end'); } - // Deduplicate edges per source-target pair, keeping the first encountered kind + // Deduplicate edges per source-target pair, collecting all distinct kinds const edgeMap = new Map(); for (const { source, target, edge_kind } of edges) { const key = `${source}|${target}`; const label = edge_kind === 'imports-type' ? 'imports' : edge_kind; - if (!edgeMap.has(key)) edgeMap.set(key, { source, target, label }); + if (!edgeMap.has(key)) edgeMap.set(key, { source, target, labels: new Set() }); + edgeMap.get(key).labels.add(label); } - for (const { source, target, label } of edgeMap.values()) { - lines.push(` ${nodeId(source)} -->|${label}| ${nodeId(target)}`); + for (const { source, target, labels } of edgeMap.values()) { + lines.push(` ${nodeId(source)} -->|${[...labels].join(', ')}| ${nodeId(target)}`); } } else { let edges = db From 0c10e23f0d1d4aac0d28850fa615687578b9dcdb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 10:14:31 -0700 Subject: [PATCH 6/6] fix: skip keys without :: separator in role lookup Impact: 1 functions changed, 0 affected --- src/export.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/export.js b/src/export.js index d2b8f85c..bbcbcdeb 100644 --- a/src/export.js +++ b/src/export.js @@ -307,8 +307,9 @@ export function exportMermaid(db, opts = {}) { const roleStyles = []; for (const key of allKeys) { const colonIdx = key.indexOf('::'); - const file = colonIdx !== -1 ? key.slice(0, colonIdx) : key; - const name = colonIdx !== -1 ? key.slice(colonIdx + 2) : ''; + if (colonIdx === -1) continue; + const file = key.slice(0, colonIdx); + const name = key.slice(colonIdx + 2); const row = db .prepare('SELECT role FROM nodes WHERE file = ? AND name = ? AND role IS NOT NULL LIMIT 1') .get(file, name);