@optave/codegraph 2.3.0 → 2.3.1-dev.1aeea34

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.
package/src/export.js CHANGED
@@ -125,6 +125,42 @@ export function exportDOT(db, opts = {}) {
125
125
  return lines.join('\n');
126
126
  }
127
127
 
128
+ /** Escape double quotes for Mermaid labels. */
129
+ function escapeLabel(label) {
130
+ return label.replace(/"/g, '#quot;');
131
+ }
132
+
133
+ /** Map node kind to Mermaid shape wrapper. */
134
+ function mermaidShape(kind, label) {
135
+ const escaped = escapeLabel(label);
136
+ switch (kind) {
137
+ case 'function':
138
+ case 'method':
139
+ return `(["${escaped}"])`;
140
+ case 'class':
141
+ case 'interface':
142
+ case 'type':
143
+ case 'struct':
144
+ case 'enum':
145
+ case 'trait':
146
+ case 'record':
147
+ return `{{"${escaped}"}}`;
148
+ case 'module':
149
+ return `[["${escaped}"]]`;
150
+ default:
151
+ return `["${escaped}"]`;
152
+ }
153
+ }
154
+
155
+ /** Map node role to Mermaid style colors. */
156
+ const ROLE_STYLES = {
157
+ entry: 'fill:#e8f5e9,stroke:#4caf50',
158
+ core: 'fill:#e3f2fd,stroke:#2196f3',
159
+ utility: 'fill:#f5f5f5,stroke:#9e9e9e',
160
+ dead: 'fill:#ffebee,stroke:#f44336',
161
+ leaf: 'fill:#fffde7,stroke:#fdd835',
162
+ };
163
+
128
164
  /**
129
165
  * Export the dependency graph in Mermaid format.
130
166
  */
@@ -132,12 +168,20 @@ export function exportMermaid(db, opts = {}) {
132
168
  const fileLevel = opts.fileLevel !== false;
133
169
  const noTests = opts.noTests || false;
134
170
  const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
135
- const lines = ['graph LR'];
171
+ const direction = opts.direction || 'LR';
172
+ const lines = [`flowchart ${direction}`];
173
+
174
+ let nodeCounter = 0;
175
+ const nodeIdMap = new Map();
176
+ function nodeId(key) {
177
+ if (!nodeIdMap.has(key)) nodeIdMap.set(key, `n${nodeCounter++}`);
178
+ return nodeIdMap.get(key);
179
+ }
136
180
 
137
181
  if (fileLevel) {
138
182
  let edges = db
139
183
  .prepare(`
140
- SELECT DISTINCT n1.file AS source, n2.file AS target
184
+ SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind AS edge_kind
141
185
  FROM edges e
142
186
  JOIN nodes n1 ON e.source_id = n1.id
143
187
  JOIN nodes n2 ON e.target_id = n2.id
@@ -147,32 +191,133 @@ export function exportMermaid(db, opts = {}) {
147
191
  .all(minConf);
148
192
  if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
149
193
 
194
+ // Collect all files referenced in edges
195
+ const allFiles = new Set();
150
196
  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}"]`);
197
+ allFiles.add(source);
198
+ allFiles.add(target);
199
+ }
200
+
201
+ // Build directory groupings — try DB directory nodes first, fall back to path.dirname()
202
+ const dirs = new Map();
203
+ const hasDirectoryNodes =
204
+ db.prepare("SELECT COUNT(*) as c FROM nodes WHERE kind = 'directory'").get().c > 0;
205
+
206
+ if (hasDirectoryNodes) {
207
+ const dbDirs = db.prepare("SELECT id, name FROM nodes WHERE kind = 'directory'").all();
208
+ for (const d of dbDirs) {
209
+ const containedFiles = db
210
+ .prepare(`
211
+ SELECT n.name FROM edges e
212
+ JOIN nodes n ON e.target_id = n.id
213
+ WHERE e.source_id = ? AND e.kind = 'contains' AND n.kind = 'file'
214
+ `)
215
+ .all(d.id)
216
+ .map((r) => r.name)
217
+ .filter((f) => allFiles.has(f));
218
+ if (containedFiles.length > 0) dirs.set(d.name, containedFiles);
219
+ }
220
+ } else {
221
+ for (const file of allFiles) {
222
+ const dir = path.dirname(file) || '.';
223
+ if (!dirs.has(dir)) dirs.set(dir, []);
224
+ dirs.get(dir).push(file);
225
+ }
226
+ }
227
+
228
+ // Emit subgraphs
229
+ for (const [dir, files] of [...dirs].sort((a, b) => a[0].localeCompare(b[0]))) {
230
+ const sgId = dir.replace(/[^a-zA-Z0-9]/g, '_');
231
+ lines.push(` subgraph ${sgId}["${escapeLabel(dir)}"]`);
232
+ for (const f of files) {
233
+ const nId = nodeId(f);
234
+ lines.push(` ${nId}["${escapeLabel(path.basename(f))}"]`);
235
+ }
236
+ lines.push(' end');
237
+ }
238
+
239
+ // Deduplicate edges per source-target pair, collecting all distinct kinds
240
+ const edgeMap = new Map();
241
+ for (const { source, target, edge_kind } of edges) {
242
+ const key = `${source}|${target}`;
243
+ const label = edge_kind === 'imports-type' ? 'imports' : edge_kind;
244
+ if (!edgeMap.has(key)) edgeMap.set(key, { source, target, labels: new Set() });
245
+ edgeMap.get(key).labels.add(label);
246
+ }
247
+
248
+ for (const { source, target, labels } of edgeMap.values()) {
249
+ lines.push(` ${nodeId(source)} -->|${[...labels].join(', ')}| ${nodeId(target)}`);
154
250
  }
155
251
  } else {
156
252
  let edges = db
157
253
  .prepare(`
158
- SELECT n1.name AS source_name, n1.file AS source_file,
159
- n2.name AS target_name, n2.file AS target_file
254
+ SELECT n1.name AS source_name, n1.kind AS source_kind, n1.file AS source_file,
255
+ n2.name AS target_name, n2.kind AS target_kind, n2.file AS target_file,
256
+ e.kind AS edge_kind
160
257
  FROM edges e
161
258
  JOIN nodes n1 ON e.source_id = n1.id
162
259
  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 >= ?
260
+ WHERE n1.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
261
+ AND n2.kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module')
262
+ AND e.kind = 'calls'
263
+ AND e.confidence >= ?
166
264
  `)
167
265
  .all(minConf);
168
266
  if (noTests)
169
267
  edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
170
268
 
269
+ // Group nodes by file for subgraphs
270
+ const fileNodes = new Map();
271
+ const nodeKinds = new Map();
272
+ for (const e of edges) {
273
+ const sKey = `${e.source_file}::${e.source_name}`;
274
+ const tKey = `${e.target_file}::${e.target_name}`;
275
+ nodeId(sKey);
276
+ nodeId(tKey);
277
+ nodeKinds.set(sKey, e.source_kind);
278
+ nodeKinds.set(tKey, e.target_kind);
279
+
280
+ if (!fileNodes.has(e.source_file)) fileNodes.set(e.source_file, new Map());
281
+ fileNodes.get(e.source_file).set(sKey, e.source_name);
282
+
283
+ if (!fileNodes.has(e.target_file)) fileNodes.set(e.target_file, new Map());
284
+ fileNodes.get(e.target_file).set(tKey, e.target_name);
285
+ }
286
+
287
+ // Emit subgraphs grouped by file
288
+ for (const [file, nodes] of [...fileNodes].sort((a, b) => a[0].localeCompare(b[0]))) {
289
+ const sgId = file.replace(/[^a-zA-Z0-9]/g, '_');
290
+ lines.push(` subgraph ${sgId}["${escapeLabel(file)}"]`);
291
+ for (const [key, name] of nodes) {
292
+ const kind = nodeKinds.get(key);
293
+ lines.push(` ${nodeId(key)}${mermaidShape(kind, name)}`);
294
+ }
295
+ lines.push(' end');
296
+ }
297
+
298
+ // Emit edges with labels
171
299
  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}"]`);
300
+ const sId = nodeId(`${e.source_file}::${e.source_name}`);
301
+ const tId = nodeId(`${e.target_file}::${e.target_name}`);
302
+ lines.push(` ${sId} -->|${e.edge_kind}| ${tId}`);
303
+ }
304
+
305
+ // Role styling — query roles for all referenced nodes
306
+ const allKeys = [...nodeIdMap.keys()];
307
+ const roleStyles = [];
308
+ for (const key of allKeys) {
309
+ const colonIdx = key.indexOf('::');
310
+ if (colonIdx === -1) continue;
311
+ const file = key.slice(0, colonIdx);
312
+ const name = key.slice(colonIdx + 2);
313
+ const row = db
314
+ .prepare('SELECT role FROM nodes WHERE file = ? AND name = ? AND role IS NOT NULL LIMIT 1')
315
+ .get(file, name);
316
+ if (row?.role && ROLE_STYLES[row.role]) {
317
+ roleStyles.push(` style ${nodeIdMap.get(key)} ${ROLE_STYLES[row.role]}`);
318
+ }
175
319
  }
320
+ lines.push(...roleStyles);
176
321
  }
177
322
 
178
323
  return lines.join('\n');
@@ -4,7 +4,8 @@ export function nodeEndLine(node) {
4
4
 
5
5
  export function findChild(node, type) {
6
6
  for (let i = 0; i < node.childCount; i++) {
7
- if (node.child(i).type === type) return node.child(i);
7
+ const child = node.child(i);
8
+ if (child.type === type) return child;
8
9
  }
9
10
  return null;
10
11
  }