@optave/codegraph 2.2.1 → 2.2.3-dev.44e8146

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
@@ -1,12 +1,15 @@
1
1
  import path from 'node:path';
2
2
  import { isTestFile } from './queries.js';
3
3
 
4
+ const DEFAULT_MIN_CONFIDENCE = 0.5;
5
+
4
6
  /**
5
7
  * Export the dependency graph in DOT (Graphviz) format.
6
8
  */
7
9
  export function exportDOT(db, opts = {}) {
8
10
  const fileLevel = opts.fileLevel !== false;
9
11
  const noTests = opts.noTests || false;
12
+ const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
10
13
  const lines = [
11
14
  'digraph codegraph {',
12
15
  ' rankdir=LR;',
@@ -23,8 +26,9 @@ export function exportDOT(db, opts = {}) {
23
26
  JOIN nodes n1 ON e.source_id = n1.id
24
27
  JOIN nodes n2 ON e.target_id = n2.id
25
28
  WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
29
+ AND e.confidence >= ?
26
30
  `)
27
- .all();
31
+ .all(minConf);
28
32
  if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
29
33
 
30
34
  // Try to use directory nodes from DB (built by structure analysis)
@@ -102,8 +106,9 @@ export function exportDOT(db, opts = {}) {
102
106
  JOIN nodes n2 ON e.target_id = n2.id
103
107
  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')
104
108
  AND e.kind = 'calls'
109
+ AND e.confidence >= ?
105
110
  `)
106
- .all();
111
+ .all(minConf);
107
112
  if (noTests)
108
113
  edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
109
114
 
@@ -126,6 +131,7 @@ export function exportDOT(db, opts = {}) {
126
131
  export function exportMermaid(db, opts = {}) {
127
132
  const fileLevel = opts.fileLevel !== false;
128
133
  const noTests = opts.noTests || false;
134
+ const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
129
135
  const lines = ['graph LR'];
130
136
 
131
137
  if (fileLevel) {
@@ -136,8 +142,9 @@ export function exportMermaid(db, opts = {}) {
136
142
  JOIN nodes n1 ON e.source_id = n1.id
137
143
  JOIN nodes n2 ON e.target_id = n2.id
138
144
  WHERE n1.file != n2.file AND e.kind IN ('imports', 'imports-type', 'calls')
145
+ AND e.confidence >= ?
139
146
  `)
140
- .all();
147
+ .all(minConf);
141
148
  if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
142
149
 
143
150
  for (const { source, target } of edges) {
@@ -155,8 +162,9 @@ export function exportMermaid(db, opts = {}) {
155
162
  JOIN nodes n2 ON e.target_id = n2.id
156
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')
157
164
  AND e.kind = 'calls'
165
+ AND e.confidence >= ?
158
166
  `)
159
- .all();
167
+ .all(minConf);
160
168
  if (noTests)
161
169
  edges = edges.filter((e) => !isTestFile(e.source_file) && !isTestFile(e.target_file));
162
170
 
@@ -175,6 +183,7 @@ export function exportMermaid(db, opts = {}) {
175
183
  */
176
184
  export function exportJSON(db, opts = {}) {
177
185
  const noTests = opts.noTests || false;
186
+ const minConf = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
178
187
 
179
188
  let nodes = db
180
189
  .prepare(`
@@ -185,13 +194,13 @@ export function exportJSON(db, opts = {}) {
185
194
 
186
195
  let edges = db
187
196
  .prepare(`
188
- SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind
197
+ SELECT DISTINCT n1.file AS source, n2.file AS target, e.kind, e.confidence
189
198
  FROM edges e
190
199
  JOIN nodes n1 ON e.source_id = n1.id
191
200
  JOIN nodes n2 ON e.target_id = n2.id
192
- WHERE n1.file != n2.file
201
+ WHERE n1.file != n2.file AND e.confidence >= ?
193
202
  `)
194
- .all();
203
+ .all(minConf);
195
204
  if (noTests) edges = edges.filter((e) => !isTestFile(e.source) && !isTestFile(e.target));
196
205
 
197
206
  return { nodes, edges };
@@ -139,9 +139,11 @@ export function extractSymbols(tree, _filePath) {
139
139
  if (callInfo) {
140
140
  calls.push(callInfo);
141
141
  }
142
+ if (fn.type === 'member_expression') {
143
+ const cbDef = extractCallbackDefinition(node, fn);
144
+ if (cbDef) definitions.push(cbDef);
145
+ }
142
146
  }
143
- const cbDef = extractCallbackDefinition(node);
144
- if (cbDef) definitions.push(cbDef);
145
147
  break;
146
148
  }
147
149
 
@@ -320,10 +322,6 @@ function extractReceiverName(objNode) {
320
322
  if (objNode.type === 'identifier') return objNode.text;
321
323
  if (objNode.type === 'this') return 'this';
322
324
  if (objNode.type === 'super') return 'super';
323
- if (objNode.type === 'member_expression') {
324
- const prop = objNode.childForFieldName('property');
325
- if (prop) return objNode.text;
326
- }
327
325
  return objNode.text;
328
326
  }
329
327
 
@@ -432,8 +430,8 @@ const EXPRESS_METHODS = new Set([
432
430
  ]);
433
431
  const EVENT_METHODS = new Set(['on', 'once', 'addEventListener', 'addListener']);
434
432
 
435
- function extractCallbackDefinition(callNode) {
436
- const fn = callNode.childForFieldName('function');
433
+ function extractCallbackDefinition(callNode, fn) {
434
+ if (!fn) fn = callNode.childForFieldName('function');
437
435
  if (!fn || fn.type !== 'member_expression') return null;
438
436
 
439
437
  const prop = fn.childForFieldName('property');
package/src/index.js CHANGED
@@ -21,7 +21,9 @@ export {
21
21
  buildEmbeddings,
22
22
  cosineSim,
23
23
  DEFAULT_MODEL,
24
+ EMBEDDING_STRATEGIES,
24
25
  embed,
26
+ estimateTokens,
25
27
  MODELS,
26
28
  multiSearchData,
27
29
  search,
@@ -41,6 +43,7 @@ export {
41
43
  ALL_SYMBOL_KINDS,
42
44
  contextData,
43
45
  diffImpactData,
46
+ diffImpactMermaid,
44
47
  explainData,
45
48
  FALSE_POSITIVE_CALLER_THRESHOLD,
46
49
  FALSE_POSITIVE_NAMES,
package/src/mcp.js CHANGED
@@ -8,7 +8,7 @@
8
8
  import { createRequire } from 'node:module';
9
9
  import { findCycles } from './cycles.js';
10
10
  import { findDbPath } from './db.js';
11
- import { ALL_SYMBOL_KINDS } from './queries.js';
11
+ import { ALL_SYMBOL_KINDS, diffImpactMermaid } from './queries.js';
12
12
 
13
13
  const REPO_PROP = {
14
14
  repo: {
@@ -201,6 +201,11 @@ const BASE_TOOLS = [
201
201
  ref: { type: 'string', description: 'Git ref to diff against (default: HEAD)' },
202
202
  depth: { type: 'number', description: 'Transitive caller depth', default: 3 },
203
203
  no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
204
+ format: {
205
+ type: 'string',
206
+ enum: ['json', 'mermaid'],
207
+ description: 'Output format (default: json)',
208
+ },
204
209
  },
205
210
  },
206
211
  },
@@ -467,12 +472,21 @@ export async function startMCPServer(customDbPath, options = {}) {
467
472
  });
468
473
  break;
469
474
  case 'diff_impact':
470
- result = diffImpactData(dbPath, {
471
- staged: args.staged,
472
- ref: args.ref,
473
- depth: args.depth,
474
- noTests: args.no_tests,
475
- });
475
+ if (args.format === 'mermaid') {
476
+ result = diffImpactMermaid(dbPath, {
477
+ staged: args.staged,
478
+ ref: args.ref,
479
+ depth: args.depth,
480
+ noTests: args.no_tests,
481
+ });
482
+ } else {
483
+ result = diffImpactData(dbPath, {
484
+ staged: args.staged,
485
+ ref: args.ref,
486
+ depth: args.depth,
487
+ noTests: args.no_tests,
488
+ });
489
+ }
476
490
  break;
477
491
  case 'semantic_search': {
478
492
  const { searchData } = await import('./embedder.js');
package/src/queries.js CHANGED
@@ -334,6 +334,7 @@ export function moduleMapData(customDbPath, limit = 20, opts = {}) {
334
334
  dir: path.dirname(n.file) || '.',
335
335
  inEdges: n.in_edges,
336
336
  outEdges: n.out_edges,
337
+ coupling: n.in_edges + n.out_edges,
337
338
  }));
338
339
 
339
340
  const totalNodes = db.prepare('SELECT COUNT(*) as c FROM nodes').get().c;
@@ -608,16 +609,34 @@ export function diffImpactData(customDbPath, opts = {}) {
608
609
 
609
610
  if (!diffOutput.trim()) {
610
611
  db.close();
611
- return { changedFiles: 0, affectedFunctions: [], affectedFiles: [], summary: null };
612
+ return {
613
+ changedFiles: 0,
614
+ newFiles: [],
615
+ affectedFunctions: [],
616
+ affectedFiles: [],
617
+ summary: null,
618
+ };
612
619
  }
613
620
 
614
621
  const changedRanges = new Map();
622
+ const newFiles = new Set();
615
623
  let currentFile = null;
624
+ let prevIsDevNull = false;
616
625
  for (const line of diffOutput.split('\n')) {
626
+ if (line.startsWith('--- /dev/null')) {
627
+ prevIsDevNull = true;
628
+ continue;
629
+ }
630
+ if (line.startsWith('--- ')) {
631
+ prevIsDevNull = false;
632
+ continue;
633
+ }
617
634
  const fileMatch = line.match(/^\+\+\+ b\/(.+)/);
618
635
  if (fileMatch) {
619
636
  currentFile = fileMatch[1];
620
637
  if (!changedRanges.has(currentFile)) changedRanges.set(currentFile, []);
638
+ if (prevIsDevNull) newFiles.add(currentFile);
639
+ prevIsDevNull = false;
621
640
  continue;
622
641
  }
623
642
  const hunkMatch = line.match(/^@@ .+ \+(\d+)(?:,(\d+))? @@/);
@@ -630,7 +649,13 @@ export function diffImpactData(customDbPath, opts = {}) {
630
649
 
631
650
  if (changedRanges.size === 0) {
632
651
  db.close();
633
- return { changedFiles: 0, affectedFunctions: [], affectedFiles: [], summary: null };
652
+ return {
653
+ changedFiles: 0,
654
+ newFiles: [],
655
+ affectedFunctions: [],
656
+ affectedFiles: [],
657
+ summary: null,
658
+ };
634
659
  }
635
660
 
636
661
  const affectedFunctions = [];
@@ -658,6 +683,10 @@ export function diffImpactData(customDbPath, opts = {}) {
658
683
  const visited = new Set([fn.id]);
659
684
  let frontier = [fn.id];
660
685
  let totalCallers = 0;
686
+ const levels = {};
687
+ const edges = [];
688
+ const idToKey = new Map();
689
+ idToKey.set(fn.id, `${fn.file}::${fn.name}:${fn.line}`);
661
690
  for (let d = 1; d <= maxDepth; d++) {
662
691
  const nextFrontier = [];
663
692
  for (const fid of frontier) {
@@ -673,6 +702,11 @@ export function diffImpactData(customDbPath, opts = {}) {
673
702
  visited.add(c.id);
674
703
  nextFrontier.push(c.id);
675
704
  allAffected.add(`${c.file}:${c.name}`);
705
+ const callerKey = `${c.file}::${c.name}:${c.line}`;
706
+ idToKey.set(c.id, callerKey);
707
+ if (!levels[d]) levels[d] = [];
708
+ levels[d].push({ name: c.name, kind: c.kind, file: c.file, line: c.line });
709
+ edges.push({ from: idToKey.get(fid), to: callerKey });
676
710
  totalCallers++;
677
711
  }
678
712
  }
@@ -686,6 +720,8 @@ export function diffImpactData(customDbPath, opts = {}) {
686
720
  file: fn.file,
687
721
  line: fn.line,
688
722
  transitiveCallers: totalCallers,
723
+ levels,
724
+ edges,
689
725
  };
690
726
  });
691
727
 
@@ -695,6 +731,7 @@ export function diffImpactData(customDbPath, opts = {}) {
695
731
  db.close();
696
732
  return {
697
733
  changedFiles: changedRanges.size,
734
+ newFiles: [...newFiles],
698
735
  affectedFunctions: functionResults,
699
736
  affectedFiles: [...affectedFiles],
700
737
  summary: {
@@ -705,6 +742,120 @@ export function diffImpactData(customDbPath, opts = {}) {
705
742
  };
706
743
  }
707
744
 
745
+ export function diffImpactMermaid(customDbPath, opts = {}) {
746
+ const data = diffImpactData(customDbPath, opts);
747
+ if (data.error) return data.error;
748
+ if (data.changedFiles === 0 || data.affectedFunctions.length === 0) {
749
+ return 'flowchart TB\n none["No impacted functions detected"]';
750
+ }
751
+
752
+ const newFileSet = new Set(data.newFiles || []);
753
+ const lines = ['flowchart TB'];
754
+
755
+ // Assign stable Mermaid node IDs
756
+ let nodeCounter = 0;
757
+ const nodeIdMap = new Map();
758
+ const nodeLabels = new Map();
759
+ function nodeId(key, label) {
760
+ if (!nodeIdMap.has(key)) {
761
+ nodeIdMap.set(key, `n${nodeCounter++}`);
762
+ if (label) nodeLabels.set(key, label);
763
+ }
764
+ return nodeIdMap.get(key);
765
+ }
766
+
767
+ // Register all nodes (changed functions + their callers)
768
+ for (const fn of data.affectedFunctions) {
769
+ nodeId(`${fn.file}::${fn.name}:${fn.line}`, fn.name);
770
+ for (const callers of Object.values(fn.levels || {})) {
771
+ for (const c of callers) {
772
+ nodeId(`${c.file}::${c.name}:${c.line}`, c.name);
773
+ }
774
+ }
775
+ }
776
+
777
+ // Collect all edges and determine blast radius
778
+ const allEdges = new Set();
779
+ const edgeFromNodes = new Set();
780
+ const edgeToNodes = new Set();
781
+ const changedKeys = new Set();
782
+
783
+ for (const fn of data.affectedFunctions) {
784
+ changedKeys.add(`${fn.file}::${fn.name}:${fn.line}`);
785
+ for (const edge of fn.edges || []) {
786
+ const edgeKey = `${edge.from}|${edge.to}`;
787
+ if (!allEdges.has(edgeKey)) {
788
+ allEdges.add(edgeKey);
789
+ edgeFromNodes.add(edge.from);
790
+ edgeToNodes.add(edge.to);
791
+ }
792
+ }
793
+ }
794
+
795
+ // Blast radius: caller nodes that are never a source (leaf nodes of the impact tree)
796
+ const blastRadiusKeys = new Set();
797
+ for (const key of edgeToNodes) {
798
+ if (!edgeFromNodes.has(key) && !changedKeys.has(key)) {
799
+ blastRadiusKeys.add(key);
800
+ }
801
+ }
802
+
803
+ // Intermediate callers: not changed, not blast radius
804
+ const intermediateKeys = new Set();
805
+ for (const key of edgeToNodes) {
806
+ if (!changedKeys.has(key) && !blastRadiusKeys.has(key)) {
807
+ intermediateKeys.add(key);
808
+ }
809
+ }
810
+
811
+ // Group changed functions by file
812
+ const fileGroups = new Map();
813
+ for (const fn of data.affectedFunctions) {
814
+ if (!fileGroups.has(fn.file)) fileGroups.set(fn.file, []);
815
+ fileGroups.get(fn.file).push(fn);
816
+ }
817
+
818
+ // Emit changed-file subgraphs
819
+ let sgCounter = 0;
820
+ for (const [file, fns] of fileGroups) {
821
+ const isNew = newFileSet.has(file);
822
+ const tag = isNew ? 'new' : 'modified';
823
+ const sgId = `sg${sgCounter++}`;
824
+ lines.push(` subgraph ${sgId}["${file} **(${tag})**"]`);
825
+ for (const fn of fns) {
826
+ const key = `${fn.file}::${fn.name}:${fn.line}`;
827
+ lines.push(` ${nodeIdMap.get(key)}["${fn.name}"]`);
828
+ }
829
+ lines.push(' end');
830
+ const style = isNew ? 'fill:#e8f5e9,stroke:#4caf50' : 'fill:#fff3e0,stroke:#ff9800';
831
+ lines.push(` style ${sgId} ${style}`);
832
+ }
833
+
834
+ // Emit intermediate caller nodes (outside subgraphs)
835
+ for (const key of intermediateKeys) {
836
+ lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`);
837
+ }
838
+
839
+ // Emit blast radius subgraph
840
+ if (blastRadiusKeys.size > 0) {
841
+ const sgId = `sg${sgCounter++}`;
842
+ lines.push(` subgraph ${sgId}["Callers **(blast radius)**"]`);
843
+ for (const key of blastRadiusKeys) {
844
+ lines.push(` ${nodeIdMap.get(key)}["${nodeLabels.get(key)}"]`);
845
+ }
846
+ lines.push(' end');
847
+ lines.push(` style ${sgId} fill:#f3e5f5,stroke:#9c27b0`);
848
+ }
849
+
850
+ // Emit edges (impact flows from changed fn toward callers)
851
+ for (const edgeKey of allEdges) {
852
+ const [from, to] = edgeKey.split('|');
853
+ lines.push(` ${nodeIdMap.get(from)} --> ${nodeIdMap.get(to)}`);
854
+ }
855
+
856
+ return lines.join('\n');
857
+ }
858
+
708
859
  export function listFunctionsData(customDbPath, opts = {}) {
709
860
  const db = openReadonlyOrFail(customDbPath);
710
861
  const noTests = opts.noTests || false;
@@ -1113,10 +1264,10 @@ export function moduleMap(customDbPath, limit = 20, opts = {}) {
1113
1264
  for (const [dir, files] of [...dirs].sort()) {
1114
1265
  console.log(` [${dir}/]`);
1115
1266
  for (const f of files) {
1116
- const total = f.inEdges + f.outEdges;
1117
- const bar = '#'.repeat(Math.min(total, 40));
1267
+ const coupling = f.inEdges + f.outEdges;
1268
+ const bar = '#'.repeat(Math.min(coupling, 40));
1118
1269
  console.log(
1119
- ` ${path.basename(f.file).padEnd(35)} <-${String(f.inEdges).padStart(3)} ->${String(f.outEdges).padStart(3)} ${bar}`,
1270
+ ` ${path.basename(f.file).padEnd(35)} <-${String(f.inEdges).padStart(3)} ->${String(f.outEdges).padStart(3)} =${String(coupling).padStart(3)} ${bar}`,
1120
1271
  );
1121
1272
  }
1122
1273
  }
@@ -1770,6 +1921,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
1770
1921
  export function explainData(target, customDbPath, opts = {}) {
1771
1922
  const db = openReadonlyOrFail(customDbPath);
1772
1923
  const noTests = opts.noTests || false;
1924
+ const depth = opts.depth || 0;
1773
1925
  const kind = isFileLikeTarget(target) ? 'file' : 'function';
1774
1926
 
1775
1927
  const dbPath = findDbPath(customDbPath);
@@ -1799,6 +1951,37 @@ export function explainData(target, customDbPath, opts = {}) {
1799
1951
  ? explainFileImpl(db, target, getFileLines)
1800
1952
  : explainFunctionImpl(db, target, noTests, getFileLines);
1801
1953
 
1954
+ // Recursive dependency explanation for function targets
1955
+ if (kind === 'function' && depth > 0 && results.length > 0) {
1956
+ const visited = new Set(results.map((r) => `${r.name}:${r.file}:${r.line}`));
1957
+
1958
+ function explainCallees(parentResults, currentDepth) {
1959
+ if (currentDepth <= 0) return;
1960
+ for (const r of parentResults) {
1961
+ const newCallees = [];
1962
+ for (const callee of r.callees) {
1963
+ const key = `${callee.name}:${callee.file}:${callee.line}`;
1964
+ if (visited.has(key)) continue;
1965
+ visited.add(key);
1966
+ const calleeResults = explainFunctionImpl(db, callee.name, noTests, getFileLines);
1967
+ const exact = calleeResults.find(
1968
+ (cr) => cr.file === callee.file && cr.line === callee.line,
1969
+ );
1970
+ if (exact) {
1971
+ exact._depth = (r._depth || 0) + 1;
1972
+ newCallees.push(exact);
1973
+ }
1974
+ }
1975
+ if (newCallees.length > 0) {
1976
+ r.depDetails = newCallees;
1977
+ explainCallees(newCallees, currentDepth - 1);
1978
+ }
1979
+ }
1980
+ }
1981
+
1982
+ explainCallees(results, depth);
1983
+ }
1984
+
1802
1985
  db.close();
1803
1986
  return { target, kind, results };
1804
1987
  }
@@ -1858,46 +2041,63 @@ export function explain(target, customDbPath, opts = {}) {
1858
2041
  console.log();
1859
2042
  }
1860
2043
  } else {
1861
- for (const r of data.results) {
2044
+ function printFunctionExplain(r, indent = '') {
1862
2045
  const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
1863
2046
  const lineInfo = r.lineCount ? `${r.lineCount} lines` : '';
1864
2047
  const summaryPart = r.summary ? ` | ${r.summary}` : '';
1865
- console.log(`\n# ${r.name} (${r.kind}) ${r.file}:${lineRange}`);
2048
+ const depthLevel = r._depth || 0;
2049
+ const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#');
2050
+ console.log(`\n${indent}${heading} ${r.name} (${r.kind}) ${r.file}:${lineRange}`);
1866
2051
  if (lineInfo || r.summary) {
1867
- console.log(` ${lineInfo}${summaryPart}`);
2052
+ console.log(`${indent} ${lineInfo}${summaryPart}`);
1868
2053
  }
1869
2054
  if (r.signature) {
1870
- if (r.signature.params != null) console.log(` Parameters: (${r.signature.params})`);
1871
- if (r.signature.returnType) console.log(` Returns: ${r.signature.returnType}`);
2055
+ if (r.signature.params != null)
2056
+ console.log(`${indent} Parameters: (${r.signature.params})`);
2057
+ if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`);
1872
2058
  }
1873
2059
 
1874
2060
  if (r.callees.length > 0) {
1875
- console.log(`\n## Calls (${r.callees.length})`);
2061
+ console.log(`\n${indent} Calls (${r.callees.length}):`);
1876
2062
  for (const c of r.callees) {
1877
- console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
2063
+ console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
1878
2064
  }
1879
2065
  }
1880
2066
 
1881
2067
  if (r.callers.length > 0) {
1882
- console.log(`\n## Called by (${r.callers.length})`);
2068
+ console.log(`\n${indent} Called by (${r.callers.length}):`);
1883
2069
  for (const c of r.callers) {
1884
- console.log(` ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
2070
+ console.log(`${indent} ${kindIcon(c.kind)} ${c.name} ${c.file}:${c.line}`);
1885
2071
  }
1886
2072
  }
1887
2073
 
1888
2074
  if (r.relatedTests.length > 0) {
1889
2075
  const label = r.relatedTests.length === 1 ? 'file' : 'files';
1890
- console.log(`\n## Tests (${r.relatedTests.length} ${label})`);
2076
+ console.log(`\n${indent} Tests (${r.relatedTests.length} ${label}):`);
1891
2077
  for (const t of r.relatedTests) {
1892
- console.log(` ${t.file}`);
2078
+ console.log(`${indent} ${t.file}`);
1893
2079
  }
1894
2080
  }
1895
2081
 
1896
2082
  if (r.callees.length === 0 && r.callers.length === 0) {
1897
- console.log(` (no call edges found -- may be invoked dynamically or via re-exports)`);
2083
+ console.log(
2084
+ `${indent} (no call edges found -- may be invoked dynamically or via re-exports)`,
2085
+ );
2086
+ }
2087
+
2088
+ // Render recursive dependency details
2089
+ if (r.depDetails && r.depDetails.length > 0) {
2090
+ console.log(`\n${indent} --- Dependencies (depth ${depthLevel + 1}) ---`);
2091
+ for (const dep of r.depDetails) {
2092
+ printFunctionExplain(dep, `${indent} `);
2093
+ }
1898
2094
  }
1899
2095
  console.log();
1900
2096
  }
2097
+
2098
+ for (const r of data.results) {
2099
+ printFunctionExplain(r);
2100
+ }
1901
2101
  }
1902
2102
  }
1903
2103
 
@@ -2079,8 +2279,12 @@ export function fnImpact(name, customDbPath, opts = {}) {
2079
2279
  }
2080
2280
 
2081
2281
  export function diffImpact(customDbPath, opts = {}) {
2282
+ if (opts.format === 'mermaid') {
2283
+ console.log(diffImpactMermaid(customDbPath, opts));
2284
+ return;
2285
+ }
2082
2286
  const data = diffImpactData(customDbPath, opts);
2083
- if (opts.json) {
2287
+ if (opts.json || opts.format === 'json') {
2084
2288
  console.log(JSON.stringify(data, null, 2));
2085
2289
  return;
2086
2290
  }
package/src/structure.js CHANGED
@@ -231,7 +231,8 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
231
231
  */
232
232
  export function structureData(customDbPath, opts = {}) {
233
233
  const db = openReadonlyOrFail(customDbPath);
234
- const filterDir = opts.directory || null;
234
+ const rawDir = opts.directory || null;
235
+ const filterDir = rawDir && normalizePath(rawDir) !== '.' ? rawDir : null;
235
236
  const maxDepth = opts.depth || null;
236
237
  const sortBy = opts.sort || 'files';
237
238
  const noTests = opts.noTests || false;
package/src/watcher.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { readFileSafe } from './builder.js';
3
4
  import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
4
5
  import { initSchema, openDb } from './db.js';
5
6
  import { appendJournalEntries } from './journal.js';
@@ -35,7 +36,7 @@ async function updateFile(_db, rootDir, filePath, stmts, engineOpts, cache) {
35
36
 
36
37
  let code;
37
38
  try {
38
- code = fs.readFileSync(filePath, 'utf-8');
39
+ code = readFileSafe(filePath);
39
40
  } catch (err) {
40
41
  warn(`Cannot read ${relPath}: ${err.message}`);
41
42
  return null;