@optave/codegraph 3.1.0 → 3.1.2

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 (83) hide show
  1. package/README.md +5 -5
  2. package/grammars/tree-sitter-go.wasm +0 -0
  3. package/package.json +8 -9
  4. package/src/ast-analysis/engine.js +365 -0
  5. package/src/ast-analysis/metrics.js +118 -0
  6. package/src/ast-analysis/rules/csharp.js +201 -0
  7. package/src/ast-analysis/rules/go.js +182 -0
  8. package/src/ast-analysis/rules/index.js +82 -0
  9. package/src/ast-analysis/rules/java.js +175 -0
  10. package/src/ast-analysis/rules/javascript.js +246 -0
  11. package/src/ast-analysis/rules/php.js +219 -0
  12. package/src/ast-analysis/rules/python.js +196 -0
  13. package/src/ast-analysis/rules/ruby.js +204 -0
  14. package/src/ast-analysis/rules/rust.js +173 -0
  15. package/src/ast-analysis/shared.js +223 -0
  16. package/src/ast-analysis/visitor-utils.js +176 -0
  17. package/src/ast-analysis/visitor.js +162 -0
  18. package/src/ast-analysis/visitors/ast-store-visitor.js +150 -0
  19. package/src/ast-analysis/visitors/cfg-visitor.js +792 -0
  20. package/src/ast-analysis/visitors/complexity-visitor.js +243 -0
  21. package/src/ast-analysis/visitors/dataflow-visitor.js +358 -0
  22. package/src/ast.js +26 -166
  23. package/src/audit.js +2 -88
  24. package/src/batch.js +0 -25
  25. package/src/boundaries.js +1 -1
  26. package/src/branch-compare.js +82 -172
  27. package/src/builder.js +48 -184
  28. package/src/cfg.js +148 -1174
  29. package/src/check.js +1 -84
  30. package/src/cli.js +118 -197
  31. package/src/cochange.js +1 -39
  32. package/src/commands/audit.js +88 -0
  33. package/src/commands/batch.js +26 -0
  34. package/src/commands/branch-compare.js +97 -0
  35. package/src/commands/cfg.js +55 -0
  36. package/src/commands/check.js +82 -0
  37. package/src/commands/cochange.js +37 -0
  38. package/src/commands/communities.js +69 -0
  39. package/src/commands/complexity.js +77 -0
  40. package/src/commands/dataflow.js +110 -0
  41. package/src/commands/flow.js +70 -0
  42. package/src/commands/manifesto.js +77 -0
  43. package/src/commands/owners.js +52 -0
  44. package/src/commands/query.js +21 -0
  45. package/src/commands/sequence.js +33 -0
  46. package/src/commands/structure.js +64 -0
  47. package/src/commands/triage.js +49 -0
  48. package/src/communities.js +22 -96
  49. package/src/complexity.js +234 -1591
  50. package/src/cycles.js +1 -1
  51. package/src/dataflow.js +274 -1352
  52. package/src/db/connection.js +88 -0
  53. package/src/db/migrations.js +312 -0
  54. package/src/db/query-builder.js +280 -0
  55. package/src/db/repository/build-stmts.js +104 -0
  56. package/src/db/repository/cfg.js +83 -0
  57. package/src/db/repository/cochange.js +41 -0
  58. package/src/db/repository/complexity.js +15 -0
  59. package/src/db/repository/dataflow.js +12 -0
  60. package/src/db/repository/edges.js +259 -0
  61. package/src/db/repository/embeddings.js +40 -0
  62. package/src/db/repository/graph-read.js +39 -0
  63. package/src/db/repository/index.js +42 -0
  64. package/src/db/repository/nodes.js +236 -0
  65. package/src/db.js +58 -399
  66. package/src/embedder.js +158 -174
  67. package/src/export.js +1 -1
  68. package/src/extractors/javascript.js +130 -5
  69. package/src/flow.js +153 -222
  70. package/src/index.js +53 -16
  71. package/src/infrastructure/result-formatter.js +21 -0
  72. package/src/infrastructure/test-filter.js +7 -0
  73. package/src/kinds.js +50 -0
  74. package/src/manifesto.js +1 -82
  75. package/src/mcp.js +37 -20
  76. package/src/owners.js +127 -182
  77. package/src/queries-cli.js +866 -0
  78. package/src/queries.js +1271 -2416
  79. package/src/sequence.js +179 -223
  80. package/src/structure.js +211 -269
  81. package/src/triage.js +117 -212
  82. package/src/viewer.js +1 -1
  83. package/src/watcher.js +7 -4
package/src/embedder.js CHANGED
@@ -2,7 +2,14 @@ import { execFileSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { createInterface } from 'node:readline';
5
- import { closeDb, findDbPath, openDb, openReadonlyOrFail } from './db.js';
5
+ import {
6
+ closeDb,
7
+ findCalleeNames,
8
+ findCallerNames,
9
+ findDbPath,
10
+ openDb,
11
+ openReadonlyOrFail,
12
+ } from './db.js';
6
13
  import { info, warn } from './logger.js';
7
14
  import { normalizeSymbol } from './queries.js';
8
15
 
@@ -166,7 +173,7 @@ function extractLeadingComment(lines, fnLineIndex) {
166
173
  * Build graph-enriched text for a symbol using dependency context.
167
174
  * Produces compact, semantic text (~100 tokens) instead of full source code.
168
175
  */
169
- function buildStructuredText(node, file, lines, calleesStmt, callersStmt) {
176
+ function buildStructuredText(node, file, lines, db) {
170
177
  const readable = splitIdentifier(node.name);
171
178
  const parts = [`${node.kind} ${node.name} (${readable}) in ${file}`];
172
179
  const startLine = Math.max(0, node.line - 1);
@@ -179,25 +186,15 @@ function buildStructuredText(node, file, lines, calleesStmt, callersStmt) {
179
186
  }
180
187
 
181
188
  // Graph context: callees (capped at 10)
182
- const callees = calleesStmt.all(node.id);
189
+ const callees = findCalleeNames(db, node.id);
183
190
  if (callees.length > 0) {
184
- parts.push(
185
- `Calls: ${callees
186
- .slice(0, 10)
187
- .map((c) => c.name)
188
- .join(', ')}`,
189
- );
191
+ parts.push(`Calls: ${callees.slice(0, 10).join(', ')}`);
190
192
  }
191
193
 
192
194
  // Graph context: callers (capped at 10)
193
- const callers = callersStmt.all(node.id);
195
+ const callers = findCallerNames(db, node.id);
194
196
  if (callers.length > 0) {
195
- parts.push(
196
- `Called by: ${callers
197
- .slice(0, 10)
198
- .map((c) => c.name)
199
- .join(', ')}`,
200
- );
197
+ parts.push(`Called by: ${callers.slice(0, 10).join(', ')}`);
201
198
  }
202
199
 
203
200
  // Leading comment (high semantic value) or first few lines of code
@@ -438,23 +435,6 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
438
435
 
439
436
  console.log(`Building embeddings for ${nodes.length} symbols (strategy: ${strategy})...`);
440
437
 
441
- // Prepare graph-context queries for structured strategy
442
- let calleesStmt, callersStmt;
443
- if (strategy === 'structured') {
444
- calleesStmt = db.prepare(`
445
- SELECT DISTINCT n.name FROM edges e
446
- JOIN nodes n ON e.target_id = n.id
447
- WHERE e.source_id = ? AND e.kind = 'calls'
448
- ORDER BY n.name
449
- `);
450
- callersStmt = db.prepare(`
451
- SELECT DISTINCT n.name FROM edges e
452
- JOIN nodes n ON e.source_id = n.id
453
- WHERE e.target_id = ? AND e.kind = 'calls'
454
- ORDER BY n.name
455
- `);
456
- }
457
-
458
438
  const byFile = new Map();
459
439
  for (const node of nodes) {
460
440
  if (!byFile.has(node.file)) byFile.set(node.file, []);
@@ -482,7 +462,7 @@ export async function buildEmbeddings(rootDir, modelKey, customDbPath, options =
482
462
  for (const node of fileNodes) {
483
463
  let text =
484
464
  strategy === 'structured'
485
- ? buildStructuredText(node, file, lines, calleesStmt, callersStmt)
465
+ ? buildStructuredText(node, file, lines, db)
486
466
  : buildSourceText(node, file, lines);
487
467
 
488
468
  // Detect and handle context window overflow
@@ -625,37 +605,39 @@ export async function searchData(query, customDbPath, opts = {}) {
625
605
  if (!prepared) return null;
626
606
  const { db, rows, modelKey, storedDim } = prepared;
627
607
 
628
- const {
629
- vectors: [queryVec],
630
- dim,
631
- } = await embed([query], modelKey);
608
+ try {
609
+ const {
610
+ vectors: [queryVec],
611
+ dim,
612
+ } = await embed([query], modelKey);
632
613
 
633
- if (storedDim && dim !== storedDim) {
634
- console.log(
635
- `Warning: query model dimension (${dim}) doesn't match stored embeddings (${storedDim}).`,
636
- );
637
- console.log(` Re-run \`codegraph embed\` with the same model, or use --model to match.`);
638
- db.close();
639
- return null;
640
- }
614
+ if (storedDim && dim !== storedDim) {
615
+ console.log(
616
+ `Warning: query model dimension (${dim}) doesn't match stored embeddings (${storedDim}).`,
617
+ );
618
+ console.log(` Re-run \`codegraph embed\` with the same model, or use --model to match.`);
619
+ return null;
620
+ }
641
621
 
642
- const hc = new Map();
643
- const results = [];
644
- for (const row of rows) {
645
- const vec = new Float32Array(new Uint8Array(row.vector).buffer);
646
- const sim = cosineSim(queryVec, vec);
622
+ const hc = new Map();
623
+ const results = [];
624
+ for (const row of rows) {
625
+ const vec = new Float32Array(new Uint8Array(row.vector).buffer);
626
+ const sim = cosineSim(queryVec, vec);
647
627
 
648
- if (sim >= minScore) {
649
- results.push({
650
- ...normalizeSymbol(row, db, hc),
651
- similarity: sim,
652
- });
628
+ if (sim >= minScore) {
629
+ results.push({
630
+ ...normalizeSymbol(row, db, hc),
631
+ similarity: sim,
632
+ });
633
+ }
653
634
  }
654
- }
655
635
 
656
- results.sort((a, b) => b.similarity - a.similarity);
657
- db.close();
658
- return { results: results.slice(0, limit) };
636
+ results.sort((a, b) => b.similarity - a.similarity);
637
+ return { results: results.slice(0, limit) };
638
+ } finally {
639
+ db.close();
640
+ }
659
641
  }
660
642
 
661
643
  /**
@@ -671,82 +653,84 @@ export async function multiSearchData(queries, customDbPath, opts = {}) {
671
653
  if (!prepared) return null;
672
654
  const { db, rows, modelKey, storedDim } = prepared;
673
655
 
674
- const { vectors: queryVecs, dim } = await embed(queries, modelKey);
675
-
676
- // Warn about similar queries that may bias RRF results
677
- const SIMILARITY_WARN_THRESHOLD = 0.85;
678
- for (let i = 0; i < queryVecs.length; i++) {
679
- for (let j = i + 1; j < queryVecs.length; j++) {
680
- const sim = cosineSim(queryVecs[i], queryVecs[j]);
681
- if (sim >= SIMILARITY_WARN_THRESHOLD) {
682
- warn(
683
- `Queries "${queries[i]}" and "${queries[j]}" are very similar ` +
684
- `(${(sim * 100).toFixed(0)}% cosine similarity). ` +
685
- `This may bias RRF results toward their shared matches. ` +
686
- `Consider using more distinct queries.`,
687
- );
656
+ try {
657
+ const { vectors: queryVecs, dim } = await embed(queries, modelKey);
658
+
659
+ // Warn about similar queries that may bias RRF results
660
+ const SIMILARITY_WARN_THRESHOLD = 0.85;
661
+ for (let i = 0; i < queryVecs.length; i++) {
662
+ for (let j = i + 1; j < queryVecs.length; j++) {
663
+ const sim = cosineSim(queryVecs[i], queryVecs[j]);
664
+ if (sim >= SIMILARITY_WARN_THRESHOLD) {
665
+ warn(
666
+ `Queries "${queries[i]}" and "${queries[j]}" are very similar ` +
667
+ `(${(sim * 100).toFixed(0)}% cosine similarity). ` +
668
+ `This may bias RRF results toward their shared matches. ` +
669
+ `Consider using more distinct queries.`,
670
+ );
671
+ }
688
672
  }
689
673
  }
690
- }
691
674
 
692
- if (storedDim && dim !== storedDim) {
693
- console.log(
694
- `Warning: query model dimension (${dim}) doesn't match stored embeddings (${storedDim}).`,
695
- );
696
- console.log(` Re-run \`codegraph embed\` with the same model, or use --model to match.`);
697
- db.close();
698
- return null;
699
- }
675
+ if (storedDim && dim !== storedDim) {
676
+ console.log(
677
+ `Warning: query model dimension (${dim}) doesn't match stored embeddings (${storedDim}).`,
678
+ );
679
+ console.log(` Re-run \`codegraph embed\` with the same model, or use --model to match.`);
680
+ return null;
681
+ }
700
682
 
701
- // Parse row vectors once
702
- const rowVecs = rows.map((row) => new Float32Array(new Uint8Array(row.vector).buffer));
683
+ // Parse row vectors once
684
+ const rowVecs = rows.map((row) => new Float32Array(new Uint8Array(row.vector).buffer));
703
685
 
704
- // For each query: compute similarities, filter by minScore, rank
705
- const perQueryRanked = queries.map((_query, qi) => {
706
- const scored = [];
707
- for (let ri = 0; ri < rows.length; ri++) {
708
- const sim = cosineSim(queryVecs[qi], rowVecs[ri]);
709
- if (sim >= minScore) {
710
- scored.push({ rowIndex: ri, similarity: sim });
686
+ // For each query: compute similarities, filter by minScore, rank
687
+ const perQueryRanked = queries.map((_query, qi) => {
688
+ const scored = [];
689
+ for (let ri = 0; ri < rows.length; ri++) {
690
+ const sim = cosineSim(queryVecs[qi], rowVecs[ri]);
691
+ if (sim >= minScore) {
692
+ scored.push({ rowIndex: ri, similarity: sim });
693
+ }
711
694
  }
712
- }
713
- scored.sort((a, b) => b.similarity - a.similarity);
714
- // Assign 1-indexed ranks
715
- return scored.map((item, rank) => ({ ...item, rank: rank + 1 }));
716
- });
695
+ scored.sort((a, b) => b.similarity - a.similarity);
696
+ // Assign 1-indexed ranks
697
+ return scored.map((item, rank) => ({ ...item, rank: rank + 1 }));
698
+ });
717
699
 
718
- // Fuse results using RRF: for each unique row, sum 1/(k + rank_i) across queries
719
- const fusionMap = new Map(); // rowIndex -> { rrfScore, queryScores[] }
720
- for (let qi = 0; qi < queries.length; qi++) {
721
- for (const item of perQueryRanked[qi]) {
722
- if (!fusionMap.has(item.rowIndex)) {
723
- fusionMap.set(item.rowIndex, { rrfScore: 0, queryScores: [] });
700
+ // Fuse results using RRF: for each unique row, sum 1/(k + rank_i) across queries
701
+ const fusionMap = new Map(); // rowIndex -> { rrfScore, queryScores[] }
702
+ for (let qi = 0; qi < queries.length; qi++) {
703
+ for (const item of perQueryRanked[qi]) {
704
+ if (!fusionMap.has(item.rowIndex)) {
705
+ fusionMap.set(item.rowIndex, { rrfScore: 0, queryScores: [] });
706
+ }
707
+ const entry = fusionMap.get(item.rowIndex);
708
+ entry.rrfScore += 1 / (k + item.rank);
709
+ entry.queryScores.push({
710
+ query: queries[qi],
711
+ similarity: item.similarity,
712
+ rank: item.rank,
713
+ });
724
714
  }
725
- const entry = fusionMap.get(item.rowIndex);
726
- entry.rrfScore += 1 / (k + item.rank);
727
- entry.queryScores.push({
728
- query: queries[qi],
729
- similarity: item.similarity,
730
- rank: item.rank,
715
+ }
716
+
717
+ // Build results sorted by RRF score
718
+ const hc = new Map();
719
+ const results = [];
720
+ for (const [rowIndex, entry] of fusionMap) {
721
+ const row = rows[rowIndex];
722
+ results.push({
723
+ ...normalizeSymbol(row, db, hc),
724
+ rrf: entry.rrfScore,
725
+ queryScores: entry.queryScores,
731
726
  });
732
727
  }
733
- }
734
728
 
735
- // Build results sorted by RRF score
736
- const hc = new Map();
737
- const results = [];
738
- for (const [rowIndex, entry] of fusionMap) {
739
- const row = rows[rowIndex];
740
- results.push({
741
- ...normalizeSymbol(row, db, hc),
742
- rrf: entry.rrfScore,
743
- queryScores: entry.queryScores,
744
- });
729
+ results.sort((a, b) => b.rrf - a.rrf);
730
+ return { results: results.slice(0, limit) };
731
+ } finally {
732
+ db.close();
745
733
  }
746
-
747
- results.sort((a, b) => b.rrf - a.rrf);
748
- db.close();
749
- return { results: results.slice(0, limit) };
750
734
  }
751
735
 
752
736
  /**
@@ -788,64 +772,64 @@ export function ftsSearchData(query, customDbPath, opts = {}) {
788
772
 
789
773
  const db = openReadonlyOrFail(customDbPath);
790
774
 
791
- if (!hasFtsIndex(db)) {
792
- db.close();
793
- return null;
794
- }
795
-
796
- const ftsQuery = sanitizeFtsQuery(query);
797
- if (!ftsQuery) {
798
- db.close();
799
- return { results: [] };
800
- }
775
+ try {
776
+ if (!hasFtsIndex(db)) {
777
+ return null;
778
+ }
801
779
 
802
- let sql = `
803
- SELECT f.rowid AS node_id, rank AS bm25_score,
804
- n.name, n.kind, n.file, n.line, n.end_line, n.role
805
- FROM fts_index f
806
- JOIN nodes n ON f.rowid = n.id
807
- WHERE fts_index MATCH ?
808
- `;
809
- const params = [ftsQuery];
780
+ const ftsQuery = sanitizeFtsQuery(query);
781
+ if (!ftsQuery) {
782
+ return { results: [] };
783
+ }
810
784
 
811
- if (opts.kind) {
812
- sql += ' AND n.kind = ?';
813
- params.push(opts.kind);
814
- }
785
+ let sql = `
786
+ SELECT f.rowid AS node_id, rank AS bm25_score,
787
+ n.name, n.kind, n.file, n.line, n.end_line, n.role
788
+ FROM fts_index f
789
+ JOIN nodes n ON f.rowid = n.id
790
+ WHERE fts_index MATCH ?
791
+ `;
792
+ const params = [ftsQuery];
793
+
794
+ if (opts.kind) {
795
+ sql += ' AND n.kind = ?';
796
+ params.push(opts.kind);
797
+ }
815
798
 
816
- const isGlob = opts.filePattern && /[*?[\]]/.test(opts.filePattern);
817
- if (opts.filePattern && !isGlob) {
818
- sql += ' AND n.file LIKE ?';
819
- params.push(`%${opts.filePattern}%`);
820
- }
799
+ const isGlob = opts.filePattern && /[*?[\]]/.test(opts.filePattern);
800
+ if (opts.filePattern && !isGlob) {
801
+ sql += ' AND n.file LIKE ?';
802
+ params.push(`%${opts.filePattern}%`);
803
+ }
821
804
 
822
- sql += ' ORDER BY rank LIMIT ?';
823
- params.push(limit * 5); // fetch generous set for post-filtering
805
+ sql += ' ORDER BY rank LIMIT ?';
806
+ params.push(limit * 5); // fetch generous set for post-filtering
824
807
 
825
- let rows;
826
- try {
827
- rows = db.prepare(sql).all(...params);
828
- } catch {
829
- // Invalid FTS5 query syntax — return empty
830
- db.close();
831
- return { results: [] };
832
- }
808
+ let rows;
809
+ try {
810
+ rows = db.prepare(sql).all(...params);
811
+ } catch {
812
+ // Invalid FTS5 query syntax — return empty
813
+ return { results: [] };
814
+ }
833
815
 
834
- if (isGlob) {
835
- rows = rows.filter((row) => globMatch(row.file, opts.filePattern));
836
- }
837
- if (noTests) {
838
- rows = rows.filter((row) => !TEST_PATTERN.test(row.file));
839
- }
816
+ if (isGlob) {
817
+ rows = rows.filter((row) => globMatch(row.file, opts.filePattern));
818
+ }
819
+ if (noTests) {
820
+ rows = rows.filter((row) => !TEST_PATTERN.test(row.file));
821
+ }
840
822
 
841
- const hc = new Map();
842
- const results = rows.slice(0, limit).map((row) => ({
843
- ...normalizeSymbol(row, db, hc),
844
- bm25Score: -row.bm25_score, // FTS5 rank is negative; negate for display
845
- }));
823
+ const hc = new Map();
824
+ const results = rows.slice(0, limit).map((row) => ({
825
+ ...normalizeSymbol(row, db, hc),
826
+ bm25Score: -row.bm25_score, // FTS5 rank is negative; negate for display
827
+ }));
846
828
 
847
- db.close();
848
- return { results };
829
+ return { results };
830
+ } finally {
831
+ db.close();
832
+ }
849
833
  }
850
834
 
851
835
  /**
package/src/export.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
+ import { isTestFile } from './infrastructure/test-filter.js';
2
3
  import { paginateResult } from './paginate.js';
3
- import { isTestFile } from './queries.js';
4
4
 
5
5
  const DEFAULT_MIN_CONFIDENCE = 0.5;
6
6
 
@@ -1,3 +1,4 @@
1
+ import { debug } from '../logger.js';
1
2
  import { findChild, nodeEndLine } from './helpers.js';
2
3
 
3
4
  /**
@@ -173,6 +174,9 @@ function extractSymbolsQuery(tree, query) {
173
174
  // Extract top-level constants via targeted walk (query patterns don't cover these)
174
175
  extractConstantsWalk(tree.rootNode, definitions);
175
176
 
177
+ // Extract dynamic import() calls via targeted walk (query patterns don't match `import` function type)
178
+ extractDynamicImportsWalk(tree.rootNode, imports);
179
+
176
180
  return { definitions, calls, imports, classes, exports: exps };
177
181
  }
178
182
 
@@ -224,6 +228,41 @@ function extractConstantsWalk(rootNode, definitions) {
224
228
  }
225
229
  }
226
230
 
231
+ /**
232
+ * Recursive walk to find dynamic import() calls.
233
+ * Query patterns match call_expression with identifier/member_expression/subscript_expression
234
+ * functions, but import() has function type `import` which none of those patterns cover.
235
+ */
236
+ function extractDynamicImportsWalk(node, imports) {
237
+ if (node.type === 'call_expression') {
238
+ const fn = node.childForFieldName('function');
239
+ if (fn && fn.type === 'import') {
240
+ const args = node.childForFieldName('arguments') || findChild(node, 'arguments');
241
+ if (args) {
242
+ const strArg = findChild(args, 'string');
243
+ if (strArg) {
244
+ const modPath = strArg.text.replace(/['"]/g, '');
245
+ const names = extractDynamicImportNames(node);
246
+ imports.push({
247
+ source: modPath,
248
+ names,
249
+ line: node.startPosition.row + 1,
250
+ dynamicImport: true,
251
+ });
252
+ } else {
253
+ debug(
254
+ `Skipping non-static dynamic import() at line ${node.startPosition.row + 1} (template literal or variable)`,
255
+ );
256
+ }
257
+ }
258
+ return; // no need to recurse into import() children
259
+ }
260
+ }
261
+ for (let i = 0; i < node.childCount; i++) {
262
+ extractDynamicImportsWalk(node.child(i), imports);
263
+ }
264
+ }
265
+
227
266
  function handleCommonJSAssignment(left, right, node, imports) {
228
267
  if (!left || !right) return;
229
268
  const leftText = left.text;
@@ -455,11 +494,36 @@ function extractSymbolsWalk(tree) {
455
494
  case 'call_expression': {
456
495
  const fn = node.childForFieldName('function');
457
496
  if (fn) {
458
- const callInfo = extractCallInfo(fn, node);
459
- if (callInfo) calls.push(callInfo);
460
- if (fn.type === 'member_expression') {
461
- const cbDef = extractCallbackDefinition(node, fn);
462
- if (cbDef) definitions.push(cbDef);
497
+ // Dynamic import(): import('./foo.js') → extract as an import entry
498
+ if (fn.type === 'import') {
499
+ const args = node.childForFieldName('arguments') || findChild(node, 'arguments');
500
+ if (args) {
501
+ const strArg = findChild(args, 'string');
502
+ if (strArg) {
503
+ const modPath = strArg.text.replace(/['"]/g, '');
504
+ // Extract destructured names from parent context:
505
+ // const { a, b } = await import('./foo.js')
506
+ // (standalone import('./foo.js').then(...) calls produce an edge with empty names)
507
+ const names = extractDynamicImportNames(node);
508
+ imports.push({
509
+ source: modPath,
510
+ names,
511
+ line: node.startPosition.row + 1,
512
+ dynamicImport: true,
513
+ });
514
+ } else {
515
+ debug(
516
+ `Skipping non-static dynamic import() at line ${node.startPosition.row + 1} (template literal or variable)`,
517
+ );
518
+ }
519
+ }
520
+ } else {
521
+ const callInfo = extractCallInfo(fn, node);
522
+ if (callInfo) calls.push(callInfo);
523
+ if (fn.type === 'member_expression') {
524
+ const cbDef = extractCallbackDefinition(node, fn);
525
+ if (cbDef) definitions.push(cbDef);
526
+ }
463
527
  }
464
528
  }
465
529
  break;
@@ -941,3 +1005,64 @@ function extractImportNames(node) {
941
1005
  scan(node);
942
1006
  return names;
943
1007
  }
1008
+
1009
+ /**
1010
+ * Extract destructured names from a dynamic import() call expression.
1011
+ *
1012
+ * Handles:
1013
+ * const { a, b } = await import('./foo.js') → ['a', 'b']
1014
+ * const mod = await import('./foo.js') → ['mod']
1015
+ * import('./foo.js') → [] (no names extractable)
1016
+ *
1017
+ * Walks up the AST from the call_expression to find the enclosing
1018
+ * variable_declarator and reads the name/object_pattern.
1019
+ */
1020
+ function extractDynamicImportNames(callNode) {
1021
+ // Walk up: call_expression → await_expression → variable_declarator
1022
+ let current = callNode.parent;
1023
+ // Skip await_expression wrapper if present
1024
+ if (current && current.type === 'await_expression') current = current.parent;
1025
+ // We should now be at a variable_declarator (or not, if standalone import())
1026
+ if (!current || current.type !== 'variable_declarator') return [];
1027
+
1028
+ const nameNode = current.childForFieldName('name');
1029
+ if (!nameNode) return [];
1030
+
1031
+ // const { a, b } = await import(...) → object_pattern
1032
+ if (nameNode.type === 'object_pattern') {
1033
+ const names = [];
1034
+ for (let i = 0; i < nameNode.childCount; i++) {
1035
+ const child = nameNode.child(i);
1036
+ if (child.type === 'shorthand_property_identifier_pattern') {
1037
+ names.push(child.text);
1038
+ } else if (child.type === 'pair_pattern') {
1039
+ // { a: localName } → use localName (the alias) for the local binding,
1040
+ // but use the key (original name) for import resolution
1041
+ const key = child.childForFieldName('key');
1042
+ if (key) names.push(key.text);
1043
+ }
1044
+ }
1045
+ return names;
1046
+ }
1047
+
1048
+ // const mod = await import(...) → identifier (namespace-like import)
1049
+ if (nameNode.type === 'identifier') {
1050
+ return [nameNode.text];
1051
+ }
1052
+
1053
+ // const [a, b] = await import(...) → array_pattern (rare but possible)
1054
+ if (nameNode.type === 'array_pattern') {
1055
+ const names = [];
1056
+ for (let i = 0; i < nameNode.childCount; i++) {
1057
+ const child = nameNode.child(i);
1058
+ if (child.type === 'identifier') names.push(child.text);
1059
+ else if (child.type === 'rest_pattern') {
1060
+ const inner = child.child(0) || child.childForFieldName('name');
1061
+ if (inner && inner.type === 'identifier') names.push(inner.text);
1062
+ }
1063
+ }
1064
+ return names;
1065
+ }
1066
+
1067
+ return [];
1068
+ }