Skip to content

Commit ae301c0

Browse files
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
1 parent c21c387 commit ae301c0

4 files changed

Lines changed: 277 additions & 18 deletions

File tree

src/cli.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,13 +274,15 @@ program
274274
.option('-T, --no-tests', 'Exclude test/spec files')
275275
.option('--include-tests', 'Include test/spec files (overrides excludeTests config)')
276276
.option('--min-confidence <score>', 'Minimum edge confidence threshold (default: 0.5)', '0.5')
277+
.option('--direction <dir>', 'Flowchart direction for Mermaid: TB, LR, RL, BT', 'LR')
277278
.option('-o, --output <file>', 'Write to file instead of stdout')
278279
.action((opts) => {
279280
const db = openReadonlyOrFail(opts.db);
280281
const exportOpts = {
281282
fileLevel: !opts.functions,
282283
noTests: resolveNoTests(opts),
283284
minConfidence: parseFloat(opts.minConfidence),
285+
direction: opts.direction,
284286
};
285287

286288
let output;

src/export.js

Lines changed: 148 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -125,19 +125,57 @@ export function exportDOT(db, opts = {}) {
125125
return lines.join('\n');
126126
}
127127

128+
/** Map node kind to Mermaid shape wrapper. */
129+
function mermaidShape(kind, label) {
130+
switch (kind) {
131+
case 'function':
132+
case 'method':
133+
return `(["${label}"])`;
134+
case 'class':
135+
case 'interface':
136+
case 'type':
137+
case 'struct':
138+
case 'enum':
139+
case 'trait':
140+
case 'record':
141+
return `{{"${label}"}}`;
142+
case 'module':
143+
return `[["${label}"]]`;
144+
default:
145+
return `["${label}"]`;
146+
}
147+
}
148+
149+
/** Map node role to Mermaid style colors. */
150+
const ROLE_STYLES = {
151+
entry: 'fill:#e8f5e9,stroke:#4caf50',
152+
core: 'fill:#e3f2fd,stroke:#2196f3',
153+
utility: 'fill:#f5f5f5,stroke:#9e9e9e',
154+
dead: 'fill:#ffebee,stroke:#f44336',
155+
leaf: 'fill:#fffde7,stroke:#fdd835',
156+
};
157+
128158
/**
129159
* Export the dependency graph in Mermaid format.
130160
*/
131161
export function exportMermaid(db, opts = {}) {
132162
const fileLevel = opts.fileLevel !== false;
133163
const noTests = opts.noTests || false;
134164
const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
135-
const lines = ['graph LR'];
165+
const direction = opts.direction || 'LR';
166+
const lines = [`flowchart ${direction}`];
167+
168+
let nodeCounter = 0;
169+
const nodeIdMap = new Map();
170+
function nodeId(key) {
171+
if (!nodeIdMap.has(key)) nodeIdMap.set(key, `n${nodeCounter++}`);
172+
return nodeIdMap.get(key);
173+
}
136174

137175
if (fileLevel) {
138176
let edges = db
139177
.prepare(`
140-
SELECT DISTINCT n1.file AS source, n2.file AS target
178+
SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind AS edge_kind
141179
FROM edges e
142180
JOIN nodes n1 ON e.source_id = n1.id
143181
JOIN nodes n2 ON e.target_id = n2.id
@@ -147,32 +185,129 @@ export function exportMermaid(db, opts = {}) {
147185
.all(minConf);
148186
if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
149187

188+
// Collect all files referenced in edges
189+
const allFiles = new Set();
150190
for (const { source, target } of edges) {
151-
const s = source.replace(/[^a-zA-Z0-9]/g, '_');
152-
const t = target.replace(/[^a-zA-Z0-9]/g, '_');
153-
lines.push(` ${s}["${source}"] --> ${t}["${target}"]`);
191+
allFiles.add(source);
192+
allFiles.add(target);
193+
}
194+
195+
// Build directory groupings — try DB directory nodes first, fall back to path.dirname()
196+
const dirs = new Map();
197+
const hasDirectoryNodes =
198+
db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'directory'").get().c > 0;
199+
200+
if (hasDirectoryNodes) {
201+
const dbDirs = db.prepare("SELECT id, name FROM nodes WHERE kind = 'directory'").all();
202+
for (const d of dbDirs) {
203+
const containedFiles = db
204+
.prepare(`
205+
SELECT n.name FROM edges e
206+
JOIN nodes n ON e.target_id = n.id
207+
WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
208+
`)
209+
.all(d.id)
210+
.map((r) => r.name)
211+
.filter((f) => allFiles.has(f));
212+
if (containedFiles.length > 0) dirs.set(d.name, containedFiles);
213+
}
214+
} else {
215+
for (const file of allFiles) {
216+
const dir = path.dirname(file) || '.';
217+
if (!dirs.has(dir)) dirs.set(dir, []);
218+
dirs.get(dir).push(file);
219+
}
220+
}
221+
222+
// Emit subgraphs
223+
for (const [dir, files] of [...dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
224+
const sgId = dir.replace(/[^a-zA-Z0-9]/g, '_');
225+
lines.push(` subgraph ${sgId}["${dir}"]`);
226+
for (const f of files) {
227+
const nId = nodeId(f);
228+
lines.push(` ${nId}["${path.basename(f)}"]`);
229+
}
230+
lines.push(' end');
231+
}
232+
233+
// Deduplicate edges per source-target pair, picking the most specific kind
234+
const edgeMap = new Map();
235+
for (const { source, target, edge_kind } of edges) {
236+
const key = `${source}|${target}`;
237+
const label = edge_kind === 'imports-type' ? 'imports' : edge_kind;
238+
if (!edgeMap.has(key)) edgeMap.set(key, { source, target, label });
239+
}
240+
241+
for (const { source, target, label } of edgeMap.values()) {
242+
lines.push(` ${nodeId(source)} -->|${label}| ${nodeId(target)}`);
154243
}
155244
} else {
156245
let edges = db
157246
.prepare(`
158-
SELECT n1.name AS source_name, n1.file AS source_file,
159-
n2.name AS target_name, n2.file AS target_file
247+
SELECT n1.name AS source_name, n1.kind AS source_kind, n1.file AS source_file,
248+
n2.name AS target_name, n2.kind AS target_kind, n2.file AS target_file,
249+
e.kind AS edge_kind
160250
FROM edges e
161251
JOIN nodes n1 ON e.source_id = n1.id
162252
JOIN nodes n2 ON e.target_id = n2.id
163-
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')
164-
AND e.kind = 'calls'
165-
AND e.confidence >= ?
253+
WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
254+
AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
255+
AND e.kind = 'calls'
256+
AND e.confidence >= ?
166257
`)
167258
.all(minConf);
168259
if (noTests)
169260
edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
170261

262+
// Group nodes by file for subgraphs
263+
const fileNodes = new Map();
264+
const nodeKinds = new Map();
265+
for (const e of edges) {
266+
const sKey = `${e.source_file}::${e.source_name}`;
267+
const tKey = `${e.target_file}::${e.target_name}`;
268+
nodeId(sKey);
269+
nodeId(tKey);
270+
nodeKinds.set(sKey, e.source_kind);
271+
nodeKinds.set(tKey, e.target_kind);
272+
273+
if (!fileNodes.has(e.source_file)) fileNodes.set(e.source_file, new Map());
274+
fileNodes.get(e.source_file).set(sKey, e.source_name);
275+
276+
if (!fileNodes.has(e.target_file)) fileNodes.set(e.target_file, new Map());
277+
fileNodes.get(e.target_file).set(tKey, e.target_name);
278+
}
279+
280+
// Emit subgraphs grouped by file
281+
for (const [file, nodes] of [...fileNodes].sort((a, b) => a[0].localeCompare(b[0]))) {
282+
const sgId = file.replace(/[^a-zA-Z0-9]/g, '_');
283+
lines.push(` subgraph ${sgId}["${file}"]`);
284+
for (const [key, name] of nodes) {
285+
const kind = nodeKinds.get(key);
286+
lines.push(` ${nodeId(key)}${mermaidShape(kind, name)}`);
287+
}
288+
lines.push(' end');
289+
}
290+
291+
// Emit edges with labels
171292
for (const e of edges) {
172-
const sId = `${e.source_file}_${e.source_name}`.replace(/[^a-zA-Z0-9]/g, '_');
173-
const tId = `${e.target_file}_${e.target_name}`.replace(/[^a-zA-Z0-9]/g, '_');
174-
lines.push(` ${sId}["${e.source_name}"] --> ${tId}["${e.target_name}"]`);
293+
const sId = nodeId(`${e.source_file}::${e.source_name}`);
294+
const tId = nodeId(`${e.target_file}::${e.target_name}`);
295+
lines.push(` ${sId} -->|${e.edge_kind}| ${tId}`);
296+
}
297+
298+
// Role styling — query roles for all referenced nodes
299+
const allKeys = [...nodeIdMap.keys()];
300+
const roleStyles = [];
301+
for (const key of allKeys) {
302+
const [file, name] = key.split('::');
303+
const row = db
304+
.prepare('SELECT role FROM nodes WHERE file = ? AND name = ? AND role IS NOT NULL LIMIT 1')
305+
.get(file, name);
306+
if (row?.role && ROLE_STYLES[row.role]) {
307+
roleStyles.push(` style ${nodeIdMap.get(key)} ${ROLE_STYLES[row.role]}`);
308+
}
175309
}
310+
lines.push(...roleStyles);
176311
}
177312

178313
return lines.join('\n');

tests/graph/export.test.js

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,65 @@ describe('exportDOT', () => {
4343
});
4444

4545
describe('exportMermaid', () => {
46-
it('generates valid Mermaid syntax', () => {
46+
it('generates valid Mermaid syntax with flowchart LR default', () => {
4747
const db = createTestDb();
4848
const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0);
4949
const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0);
5050
insertEdge(db, a, b, 'imports');
5151

5252
const mermaid = exportMermaid(db);
53-
expect(mermaid).toContain('graph LR');
53+
expect(mermaid).toContain('flowchart LR');
5454
expect(mermaid).toContain('-->');
5555
db.close();
5656
});
57+
58+
it('uses custom direction option', () => {
59+
const db = createTestDb();
60+
const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0);
61+
const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0);
62+
insertEdge(db, a, b, 'imports');
63+
64+
const mermaid = exportMermaid(db, { direction: 'TB' });
65+
expect(mermaid).toContain('flowchart TB');
66+
db.close();
67+
});
68+
69+
it('groups files into directory subgraphs', () => {
70+
const db = createTestDb();
71+
const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0);
72+
const b = insertNode(db, 'lib/b.js', 'file', 'lib/b.js', 0);
73+
insertEdge(db, a, b, 'imports');
74+
75+
const mermaid = exportMermaid(db);
76+
expect(mermaid).toContain('subgraph');
77+
expect(mermaid).toContain('"src"');
78+
expect(mermaid).toContain('"lib"');
79+
expect(mermaid).toContain('end');
80+
db.close();
81+
});
82+
83+
it('adds edge labels from edge kind', () => {
84+
const db = createTestDb();
85+
const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0);
86+
const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0);
87+
insertEdge(db, a, b, 'imports');
88+
89+
const mermaid = exportMermaid(db);
90+
expect(mermaid).toContain('-->|imports|');
91+
db.close();
92+
});
93+
94+
it('collapses imports-type to imports label', () => {
95+
const db = createTestDb();
96+
const a = insertNode(db, 'src/a.js', 'file', 'src/a.js', 0);
97+
const b = insertNode(db, 'src/b.js', 'file', 'src/b.js', 0);
98+
insertEdge(db, a, b, 'imports-type');
99+
100+
const mermaid = exportMermaid(db);
101+
expect(mermaid).toContain('-->|imports|');
102+
expect(mermaid).not.toContain('imports-type');
103+
db.close();
104+
});
57105
});
58106

59107
describe('exportDOT — function-level', () => {
@@ -107,12 +155,86 @@ describe('exportMermaid — function-level', () => {
107155
insertEdge(db, fnA, fnB, 'calls');
108156

109157
const mermaid = exportMermaid(db, { fileLevel: false });
110-
expect(mermaid).toContain('graph LR');
158+
expect(mermaid).toContain('flowchart LR');
111159
expect(mermaid).toContain('doWork');
112160
expect(mermaid).toContain('helper');
113161
expect(mermaid).toContain('-->');
114162
db.close();
115163
});
164+
165+
it('uses stadium shape for functions', () => {
166+
const db = createTestDb();
167+
const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
168+
const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10);
169+
insertEdge(db, fnA, fnB, 'calls');
170+
171+
const mermaid = exportMermaid(db, { fileLevel: false });
172+
expect(mermaid).toContain('(["doWork"])');
173+
expect(mermaid).toContain('(["helper"])');
174+
db.close();
175+
});
176+
177+
it('uses hexagon shape for classes', () => {
178+
const db = createTestDb();
179+
const cls = insertNode(db, 'MyClass', 'class', 'src/a.js', 5);
180+
const fn = insertNode(db, 'helper', 'function', 'src/b.js', 10);
181+
insertEdge(db, cls, fn, 'calls');
182+
183+
const mermaid = exportMermaid(db, { fileLevel: false });
184+
expect(mermaid).toContain('{{"MyClass"}}');
185+
db.close();
186+
});
187+
188+
it('uses subroutine shape for modules', () => {
189+
const db = createTestDb();
190+
const mod = insertNode(db, 'MyModule', 'module', 'src/a.js', 5);
191+
const fn = insertNode(db, 'helper', 'function', 'src/b.js', 10);
192+
insertEdge(db, mod, fn, 'calls');
193+
194+
const mermaid = exportMermaid(db, { fileLevel: false });
195+
expect(mermaid).toContain('[["MyModule"]]');
196+
db.close();
197+
});
198+
199+
it('adds edge labels for calls', () => {
200+
const db = createTestDb();
201+
const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
202+
const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10);
203+
insertEdge(db, fnA, fnB, 'calls');
204+
205+
const mermaid = exportMermaid(db, { fileLevel: false });
206+
expect(mermaid).toContain('-->|calls|');
207+
db.close();
208+
});
209+
210+
it('groups functions by file into subgraphs', () => {
211+
const db = createTestDb();
212+
const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
213+
const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10);
214+
insertEdge(db, fnA, fnB, 'calls');
215+
216+
const mermaid = exportMermaid(db, { fileLevel: false });
217+
expect(mermaid).toContain('subgraph');
218+
expect(mermaid).toContain('"src/a.js"');
219+
expect(mermaid).toContain('"src/b.js"');
220+
expect(mermaid).toContain('end');
221+
db.close();
222+
});
223+
224+
it('applies role styling', () => {
225+
const db = createTestDb();
226+
const fnA = insertNode(db, 'doWork', 'function', 'src/a.js', 5);
227+
const fnB = insertNode(db, 'helper', 'function', 'src/b.js', 10);
228+
// Add role to the nodes
229+
db.prepare('UPDATE nodes SET role = ? WHERE id = ?').run('entry', fnA);
230+
db.prepare('UPDATE nodes SET role = ? WHERE id = ?').run('utility', fnB);
231+
insertEdge(db, fnA, fnB, 'calls');
232+
233+
const mermaid = exportMermaid(db, { fileLevel: false });
234+
expect(mermaid).toContain('fill:#e8f5e9,stroke:#4caf50');
235+
expect(mermaid).toContain('fill:#f5f5f5,stroke:#9e9e9e');
236+
db.close();
237+
});
116238
});
117239

118240
describe('exportJSON', () => {

tests/integration/cli.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,9 @@ describe('CLI smoke tests', () => {
130130
});
131131

132132
// ─── Export (Mermaid) ────────────────────────────────────────────────
133-
test('export -f mermaid outputs graph LR', () => {
133+
test('export -f mermaid outputs flowchart LR', () => {
134134
const out = run('export', '--db', dbPath, '-f', 'mermaid');
135-
expect(out).toContain('graph LR');
135+
expect(out).toContain('flowchart LR');
136136
});
137137

138138
// ─── Export (JSON) ───────────────────────────────────────────────────

0 commit comments

Comments
 (0)