devsmind-mcp 2.3.0 → 3.0.0

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 (130) hide show
  1. package/README.md +326 -234
  2. package/dist/cli/activity.d.ts +9 -0
  3. package/dist/cli/activity.js +69 -0
  4. package/dist/cli/activity.js.map +1 -0
  5. package/dist/cli/describe.d.ts +31 -0
  6. package/dist/cli/describe.js +201 -0
  7. package/dist/cli/describe.js.map +1 -0
  8. package/dist/cli/diff.d.ts +14 -0
  9. package/dist/cli/diff.js +161 -0
  10. package/dist/cli/diff.js.map +1 -0
  11. package/dist/cli/embed.d.ts +6 -0
  12. package/dist/cli/embed.js +109 -0
  13. package/dist/cli/embed.js.map +1 -0
  14. package/dist/cli/extract-agent.d.ts +66 -0
  15. package/dist/cli/extract-agent.js +192 -0
  16. package/dist/cli/extract-agent.js.map +1 -0
  17. package/dist/cli/feedback.d.ts +11 -0
  18. package/dist/cli/feedback.js +90 -0
  19. package/dist/cli/feedback.js.map +1 -0
  20. package/dist/cli/index.js +128 -5
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/init.d.ts +30 -0
  23. package/dist/cli/init.js +98 -18
  24. package/dist/cli/init.js.map +1 -1
  25. package/dist/cli/integrations/memory-topics.d.ts +44 -0
  26. package/dist/cli/integrations/memory-topics.js +295 -0
  27. package/dist/cli/integrations/memory-topics.js.map +1 -0
  28. package/dist/cli/integrations/memory.d.ts +11 -5
  29. package/dist/cli/integrations/memory.js +132 -36
  30. package/dist/cli/integrations/memory.js.map +1 -1
  31. package/dist/cli/integrations/prompt.d.ts +8 -0
  32. package/dist/cli/integrations/prompt.js +24 -3
  33. package/dist/cli/integrations/prompt.js.map +1 -1
  34. package/dist/cli/integrations/registry.d.ts +19 -5
  35. package/dist/cli/integrations/registry.js +23 -13
  36. package/dist/cli/integrations/registry.js.map +1 -1
  37. package/dist/cli/llm-client.d.ts +116 -0
  38. package/dist/cli/llm-client.js +322 -0
  39. package/dist/cli/llm-client.js.map +1 -0
  40. package/dist/cli/prune.js +4 -3
  41. package/dist/cli/prune.js.map +1 -1
  42. package/dist/cli/rule.d.ts +22 -2
  43. package/dist/cli/rule.js +116 -56
  44. package/dist/cli/rule.js.map +1 -1
  45. package/dist/cli/runner.d.ts +21 -0
  46. package/dist/cli/runner.js +94 -276
  47. package/dist/cli/runner.js.map +1 -1
  48. package/dist/cli/sync-progress.d.ts +9 -0
  49. package/dist/cli/sync-progress.js +41 -0
  50. package/dist/cli/sync-progress.js.map +1 -0
  51. package/dist/cli/sync.js +5 -2
  52. package/dist/cli/sync.js.map +1 -1
  53. package/dist/cli/view.js +3 -2
  54. package/dist/cli/view.js.map +1 -1
  55. package/dist/cli/workflow.js +26 -20
  56. package/dist/cli/workflow.js.map +1 -1
  57. package/dist/db/activity.d.ts +200 -0
  58. package/dist/db/activity.js +347 -0
  59. package/dist/db/activity.js.map +1 -0
  60. package/dist/db/analyze.js +25 -9
  61. package/dist/db/analyze.js.map +1 -1
  62. package/dist/db/database.d.ts +607 -79
  63. package/dist/db/database.js +1936 -488
  64. package/dist/db/database.js.map +1 -1
  65. package/dist/db/edges.d.ts +24 -0
  66. package/dist/db/edges.js +84 -0
  67. package/dist/db/edges.js.map +1 -1
  68. package/dist/db/embedder.d.ts +39 -0
  69. package/dist/db/embedder.js +0 -0
  70. package/dist/db/embedder.js.map +1 -0
  71. package/dist/db/feedback.d.ts +128 -0
  72. package/dist/db/feedback.js +182 -0
  73. package/dist/db/feedback.js.map +1 -0
  74. package/dist/db/file-diff.d.ts +32 -0
  75. package/dist/db/file-diff.js +110 -0
  76. package/dist/db/file-diff.js.map +1 -0
  77. package/dist/db/grep.d.ts +78 -0
  78. package/dist/db/grep.js +475 -0
  79. package/dist/db/grep.js.map +1 -0
  80. package/dist/db/message-revert.d.ts +63 -0
  81. package/dist/db/message-revert.js +258 -0
  82. package/dist/db/message-revert.js.map +1 -0
  83. package/dist/db/revert.d.ts +31 -0
  84. package/dist/db/revert.js +108 -0
  85. package/dist/db/revert.js.map +1 -0
  86. package/dist/db/schema.d.ts +49 -4
  87. package/dist/db/schema.js +125 -73
  88. package/dist/db/schema.js.map +1 -1
  89. package/dist/db/search-index.d.ts +65 -0
  90. package/dist/db/search-index.js +74 -0
  91. package/dist/db/search-index.js.map +1 -0
  92. package/dist/db/staging.d.ts +90 -5
  93. package/dist/db/staging.js +148 -22
  94. package/dist/db/staging.js.map +1 -1
  95. package/dist/mcp/server.d.ts +29 -7
  96. package/dist/mcp/server.js +2755 -745
  97. package/dist/mcp/server.js.map +1 -1
  98. package/dist/mcp/vendor/3d-force-graph.min.js +5 -0
  99. package/dist/mcp/vendor/force-graph.min.js +5 -0
  100. package/dist/mcp/vendor/model/model_int8.onnx +0 -0
  101. package/dist/mcp/vendor/model/vocab.txt +30522 -0
  102. package/dist/mcp/vendor/three.min.js +7 -0
  103. package/dist/mcp/view.css +419 -0
  104. package/dist/mcp/view.html +161 -0
  105. package/dist/mcp/view.js +245 -0
  106. package/dist/mcp/view_chat.js +382 -0
  107. package/dist/mcp/view_graph.js +576 -0
  108. package/dist/mcp/visualizer.d.ts +25 -2
  109. package/dist/mcp/visualizer.js +31 -4
  110. package/dist/mcp/visualizer.js.map +1 -1
  111. package/dist/utils/ast.d.ts +187 -0
  112. package/dist/utils/ast.js +640 -20
  113. package/dist/utils/ast.js.map +1 -1
  114. package/dist/utils/diff.d.ts +44 -0
  115. package/dist/utils/diff.js +78 -0
  116. package/dist/utils/diff.js.map +1 -0
  117. package/dist/utils/edit.d.ts +47 -0
  118. package/dist/utils/edit.js +196 -0
  119. package/dist/utils/edit.js.map +1 -0
  120. package/dist/utils/scanner.js +12 -4
  121. package/dist/utils/scanner.js.map +1 -1
  122. package/dist/utils/tokenize.d.ts +45 -0
  123. package/dist/utils/tokenize.js +129 -0
  124. package/dist/utils/tokenize.js.map +1 -0
  125. package/dist/utils/version.d.ts +14 -0
  126. package/dist/utils/version.js +61 -0
  127. package/dist/utils/version.js.map +1 -0
  128. package/package.json +18 -5
  129. package/dist/mcp/visualizer_2d.html +0 -635
  130. package/dist/mcp/visualizer_3d.html +0 -613
package/dist/utils/ast.js CHANGED
@@ -33,13 +33,84 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AST_PARSEABLE_EXTENSIONS = void 0;
37
+ exports.isAstParseable = isAstParseable;
38
+ exports.normalizeFsPath = normalizeFsPath;
39
+ exports.invalidateParsedFile = invalidateParsedFile;
36
40
  exports.parseNodeId = parseNodeId;
41
+ exports.detectRtkEndpointAliases = detectRtkEndpointAliases;
42
+ exports.detectRtkEndpointNodes = detectRtkEndpointNodes;
37
43
  exports.resolveConnectionsLocally = resolveConnectionsLocally;
44
+ exports.locateNodeInFile = locateNodeInFile;
38
45
  exports.extractNodeFromFile = extractNodeFromFile;
46
+ exports.enumerateFileCandidates = enumerateFileCandidates;
47
+ exports.outlineFile = outlineFile;
48
+ exports.listFileImports = listFileImports;
49
+ exports.findTouchedSymbols = findTouchedSymbols;
39
50
  const fs = __importStar(require("fs"));
40
51
  const path = __importStar(require("path"));
41
52
  const ts = __importStar(require("typescript"));
42
53
  const config_1 = require("./config");
54
+ /**
55
+ * Extensions the TypeScript parser can read, and therefore the only ones where a symbol's exact
56
+ * span is knowable — live code extraction, AST edge resolution, and in-place editing are all
57
+ * limited to these. Every other indexed language falls back to regex reference matching.
58
+ *
59
+ * `.mjs`/`.cjs` are plain JavaScript and parse fine (TS understands both script kinds); they are
60
+ * listed for the same reason `.js` is. Template-based formats (`.vue`, `.svelte`) are NOT here —
61
+ * their files aren't valid JS, so the parser would choke on the markup.
62
+ */
63
+ exports.AST_PARSEABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte']);
64
+ /** Single-file-component formats: real JS, but only inside their `<script>` block. */
65
+ const SFC_EXTENSIONS = new Set(['.vue', '.svelte']);
66
+ /**
67
+ * For a single-file component, blank every character outside its `<script>` block(s) while
68
+ * preserving the file's exact length and line breaks. The parser then sees only JavaScript,
69
+ * yet every offset it reports still indexes the REAL file — which is what lets the rest of
70
+ * this module treat `.vue`/`.svelte` like any other source file. Other extensions pass through.
71
+ */
72
+ function maskNonScript(text, filePath) {
73
+ if (!SFC_EXTENSIONS.has(path.extname(filePath).toLowerCase()))
74
+ return text;
75
+ const masked = text.replace(/[^\n]/g, ' ').split('');
76
+ const re = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
77
+ for (let m = re.exec(text); m !== null; m = re.exec(text)) {
78
+ const innerStart = m.index + m[0].indexOf('>') + 1;
79
+ for (let i = 0; i < m[1].length; i++)
80
+ masked[innerStart + i] = text[innerStart + i];
81
+ }
82
+ return masked.join('');
83
+ }
84
+ /** Parse `text` as the JS/TS belonging to `filePath`, masking SFC markup first. */
85
+ function parseText(filePath, text) {
86
+ const isSfc = SFC_EXTENSIONS.has(path.extname(filePath).toLowerCase());
87
+ return ts.createSourceFile(filePath, maskNonScript(text, filePath), ts.ScriptTarget.Latest, true,
88
+ // An SFC's extension tells TS nothing; its <script> may be either language, and TS is a
89
+ // superset of JS, so parsing as TS reads both.
90
+ isSfc ? ts.ScriptKind.TS : undefined);
91
+ }
92
+ /** True when `filePath` can be parsed for exact symbol spans. */
93
+ function isAstParseable(filePath) {
94
+ return exports.AST_PARSEABLE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
95
+ }
96
+ /**
97
+ * Canonical form for COMPARING two filesystem paths — absolute, forward slashes, lower case.
98
+ *
99
+ * Windows reaches the same file through several spellings (`c:\x` vs `C:/x`), so any exact
100
+ * match against a stored path silently misses unless both sides are folded first. Comparison
101
+ * only: never write this back to disk or store it as a node's file_path.
102
+ */
103
+ function normalizeFsPath(p) {
104
+ return path.resolve(p).replace(/\\/g, '/').toLowerCase();
105
+ }
106
+ /**
107
+ * Drop a file's cached AST. The cache is mtime-keyed, but mtime resolution is coarse enough
108
+ * that a write followed immediately by a read can still be served the pre-write tree, so a
109
+ * writer must invalidate explicitly rather than trust the timestamp to have moved.
110
+ */
111
+ function invalidateParsedFile(filePath) {
112
+ sourceFileCache.delete(filePath);
113
+ }
43
114
  /**
44
115
  * Parses a DevsMind node ID into constituent parts
45
116
  */
@@ -124,6 +195,8 @@ function getFileImports(sourceFile) {
124
195
  */
125
196
  function isDefinitionName(node) {
126
197
  const parent = node.parent;
198
+ /* istanbul ignore if -- defensive: every node from a tree parsed with setParentNodes=true
199
+ (see parseText) has a parent except the SourceFile itself, which is never an Identifier. */
127
200
  if (!parent)
128
201
  return false;
129
202
  // The declared name of a declaration (function foo, class Foo, const foo, param foo, foo() {} …)
@@ -343,6 +416,128 @@ function findRouteCall(sourceFile, method, arg) {
343
416
  ts.forEachChild(sourceFile, visit);
344
417
  return found;
345
418
  }
419
+ /**
420
+ * Walks a source file for RTK Query endpoint definitions. Endpoint keys live inside
421
+ * `endpoints: (builder) => ({ ... })` — an ARROW FUNCTION BODY — so they are structurally
422
+ * invisible to the general declaration walk ({@link declarationsOverlapping}/`findNodeInAst`
423
+ * both explicitly stop at function-body boundaries, since a local const or a helper defined
424
+ * inside a function body is never a graph entity). RTK endpoints are the one exception to that
425
+ * rule this codebase knows about, which is why they need their own dedicated walk rather than
426
+ * being reachable by the general "find a declaration by name" machinery.
427
+ */
428
+ function findRtkEndpointMatches(sourceFile) {
429
+ const results = [];
430
+ function findEndpointsObject(fnBody) {
431
+ const unwrap = (e) => ts.isParenthesizedExpression(e) ? unwrap(e.expression) : e;
432
+ if (ts.isBlock(fnBody)) {
433
+ for (const stmt of fnBody.statements) {
434
+ if (ts.isReturnStatement(stmt) && stmt.expression) {
435
+ const expr = unwrap(stmt.expression);
436
+ if (ts.isObjectLiteralExpression(expr))
437
+ return expr;
438
+ }
439
+ }
440
+ return null;
441
+ }
442
+ const expr = unwrap(fnBody);
443
+ return ts.isObjectLiteralExpression(expr) ? expr : null;
444
+ }
445
+ function visit(node) {
446
+ if (ts.isCallExpression(node) &&
447
+ ts.isIdentifier(node.expression) &&
448
+ (node.expression.text === 'createApi' || node.expression.text === 'injectEndpoints') &&
449
+ node.arguments.length > 0 &&
450
+ ts.isObjectLiteralExpression(node.arguments[0])) {
451
+ const configObj = node.arguments[0];
452
+ for (const prop of configObj.properties) {
453
+ if (!ts.isPropertyAssignment(prop) ||
454
+ !ts.isIdentifier(prop.name) ||
455
+ prop.name.text !== 'endpoints' ||
456
+ !(ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer)))
457
+ continue;
458
+ const endpointsObj = findEndpointsObject(prop.initializer.body);
459
+ if (!endpointsObj)
460
+ continue;
461
+ for (const epProp of endpointsObj.properties) {
462
+ if (!(ts.isPropertyAssignment(epProp) || ts.isMethodDeclaration(epProp)) ||
463
+ !(ts.isIdentifier(epProp.name) || ts.isStringLiteral(epProp.name)))
464
+ continue;
465
+ const epName = epProp.name.text;
466
+ // `.query(...)` / `.mutation(...)` may appear either as `key: builder.query(...)`
467
+ // (property assignment) or `key(builder) { return builder.query(...) }` (method) —
468
+ // only the property-assignment shape is RTK Query's documented form, so that's all we
469
+ // match; a method-shorthand endpoint falls through and simply isn't found.
470
+ if (!ts.isPropertyAssignment(epProp) || !ts.isCallExpression(epProp.initializer))
471
+ continue;
472
+ const callExpr = epProp.initializer;
473
+ if (!ts.isPropertyAccessExpression(callExpr.expression))
474
+ continue;
475
+ const method = callExpr.expression.name.text;
476
+ if (method !== 'query' && method !== 'mutation')
477
+ continue;
478
+ results.push({ propNode: epProp, epName, method });
479
+ }
480
+ }
481
+ }
482
+ ts.forEachChild(node, visit);
483
+ }
484
+ visit(sourceFile);
485
+ return results;
486
+ }
487
+ function detectRtkEndpointAliases(filePath) {
488
+ if (!isAstParseable(filePath))
489
+ return [];
490
+ let sourceFile;
491
+ try {
492
+ sourceFile = getSourceFile(filePath);
493
+ }
494
+ catch {
495
+ return [];
496
+ }
497
+ const toPascal = (name) => (name.length ? name[0].toUpperCase() + name.slice(1) : name);
498
+ return findRtkEndpointMatches(sourceFile).map(({ epName, method }) => {
499
+ const pascal = toPascal(epName);
500
+ const aliases = method === 'query'
501
+ ? [`use${pascal}Query`, `useLazy${pascal}Query`]
502
+ : [`use${pascal}Mutation`];
503
+ return { endpointName: epName, aliases };
504
+ });
505
+ }
506
+ /**
507
+ * The candidate-enumeration counterpart to {@link detectRtkEndpointAliases}: produces full
508
+ * {@link ExtractionCandidate}s for each RTK endpoint, so {@link enumerateFileCandidates} doesn't
509
+ * silently skip them (see {@link findRtkEndpointMatches} for why the general declaration walk
510
+ * can't see them). Always `isExported:true` — an endpoint reached only through its generated hook
511
+ * is, for every practical purpose, part of the file's export surface; nothing about whether it
512
+ * "counts" as a node is a judgment call, so this feeds the SAME auto-accept path as a normal
513
+ * exported declaration.
514
+ */
515
+ function detectRtkEndpointNodes(filePath) {
516
+ if (!isAstParseable(filePath))
517
+ return [];
518
+ let sourceFile;
519
+ try {
520
+ sourceFile = getSourceFile(filePath);
521
+ }
522
+ catch {
523
+ return [];
524
+ }
525
+ return findRtkEndpointMatches(sourceFile).map(({ propNode, epName }) => {
526
+ const start = propNode.getStart(sourceFile);
527
+ const end = propNode.getEnd();
528
+ const code = propNode.getText(sourceFile);
529
+ return {
530
+ qualified: epName,
531
+ name: epName,
532
+ type: 'rtk_endpoint',
533
+ signature: code.split('\n')[0].slice(0, 200),
534
+ codeSnapshot: code,
535
+ startLine: sourceFile.getLineAndCharacterOfPosition(start).line + 1,
536
+ endLine: sourceFile.getLineAndCharacterOfPosition(end).line + 1,
537
+ isExported: true
538
+ };
539
+ });
540
+ }
346
541
  /** Navigate a dotted path through nested object-literal properties. */
347
542
  function navigateObjectPath(obj, segments) {
348
543
  let current = obj;
@@ -362,6 +557,10 @@ function navigateObjectPath(obj, segments) {
362
557
  current = null;
363
558
  }
364
559
  }
560
+ /* istanbul ignore next -- unreachable: every iteration of the loop above returns (either
561
+ `null` via a missing prop, or `prop` on the last segment), so control can only fall past
562
+ the loop when `segments` is empty. The sole caller (findInFrameworkContainer) already
563
+ guards `segments.length === 0` before ever calling this function. */
365
564
  return null;
366
565
  }
367
566
  /**
@@ -372,6 +571,9 @@ function navigateObjectPath(obj, segments) {
372
571
  * factory call's object-literal argument, so we isolate just that method.
373
572
  */
374
573
  function findInFrameworkContainer(sourceFile, segments) {
574
+ /* istanbul ignore if -- defensive: the sole caller (findNodeInAst) only reaches here when
575
+ `symbolName.includes('.')`, so `segments` (symbolName.split('.').slice(1)) always has at
576
+ least one element. */
375
577
  if (segments.length === 0)
376
578
  return null;
377
579
  const objArgs = [];
@@ -472,6 +674,10 @@ function findNodeInAst(sourceFile, className, symbolName) {
472
674
  // Fallback: className resolved to a non-class declaration (object literal, factory call,
473
675
  // etc). Search inside it for a property/method matching the member name, at any depth.
474
676
  if (!foundNode && className && containerCandidate) {
677
+ /* istanbul ignore next -- defensive: every current caller sets `className` from a symbolName
678
+ that included a '.' in the first place (locateNodeInFile/codeOfSymbol split on 2 parts,
679
+ resolveConnectionsLocally's className comes from parseNodeId, which only sets it alongside
680
+ a dotted symbolName), so the ": symbolName" (no-dot) fallback is never exercised. */
475
681
  const memberName = symbolName.includes('.') ? symbolName.split('.').pop() : symbolName;
476
682
  foundNode = findPropertyInContainer(containerCandidate, memberName);
477
683
  }
@@ -527,7 +733,7 @@ function getSourceFile(filePath, content) {
527
733
  if (cached && cached.mtimeMs === mtimeMs && content === undefined)
528
734
  return cached.sf;
529
735
  const text = content ?? fs.readFileSync(filePath, 'utf-8');
530
- const sf = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true);
736
+ const sf = parseText(filePath, text);
531
737
  sourceFileCache.set(filePath, { mtimeMs, sf });
532
738
  return sf;
533
739
  }
@@ -615,6 +821,9 @@ function resolveToExistingFile(basePaths) {
615
821
  return cached;
616
822
  let result = null;
617
823
  outer: for (const base of basePaths) {
824
+ /* istanbul ignore if -- defensive: every entry basePaths can contain comes from
825
+ resolveImportToPaths, which only ever pushes path.resolve(...) results (always a
826
+ non-empty string) or pushes nothing at all — never a falsy element. */
618
827
  if (!base)
619
828
  continue;
620
829
  for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
@@ -659,6 +868,9 @@ function getBarrelReexports(resolvedImportPath) {
659
868
  barrelMissCache.add(resolvedImportPath);
660
869
  return [];
661
870
  }
871
+ /* istanbul ignore next -- defensive TOCTOU guard: `indexPath` was confirmed to exist one line
872
+ above via fs.existsSync; the `?? -1` only matters if the file is deleted in the instant
873
+ between that check and this statSync, a race not worth simulating in a unit test. */
662
874
  const mtimeMs = statMtime(indexPath) ?? -1;
663
875
  const cached = barrelCache.get(indexPath);
664
876
  if (cached && cached.mtimeMs === mtimeMs)
@@ -698,11 +910,30 @@ function getBarrelReexports(resolvedImportPath) {
698
910
  // import alias by (case-insensitive) node name,
699
911
  // - null when there is no default export at all.
700
912
  const ANON_DEFAULT = 'anon';
913
+ /**
914
+ * The name for a non-identifier `export default <expr>` (an ExportAssignment): a named class or
915
+ * function expression's own name, or the ANON_DEFAULT marker for anything else.
916
+ */
917
+ function anonOrNamedExpressionDefaultName(expr) {
918
+ /* istanbul ignore next -- unreachable: `export default class Foo {}` / `export default
919
+ function foo() {}`, named or anonymous, always parse as a ClassDeclaration/FunctionDeclaration
920
+ (getDefaultExportName's other, `stmt.name ? ... : ANON_DEFAULT`, branch), never as this
921
+ ExportAssignment's `expression`. The only way a ClassExpression/FunctionExpression can reach
922
+ here at all is wrapped (`export default (class Foo {})`), and then `expr` is the
923
+ ParenthesizedExpression, not the class/function expression itself — so this never matches in
924
+ practice, verified directly against the TS parser. */
925
+ if ((ts.isClassExpression(expr) || ts.isFunctionExpression(expr)) && expr.name)
926
+ return expr.name.text;
927
+ return ANON_DEFAULT; // `export default Joi.object({...})`, `{...}`, `() => …`
928
+ }
701
929
  const defaultExportCache = new Map();
702
930
  function getDefaultExportName(filePath) {
703
931
  const ext = path.extname(filePath).toLowerCase();
704
932
  if (!['.ts', '.tsx', '.js', '.jsx'].includes(ext))
705
933
  return null;
934
+ /* istanbul ignore next -- defensive TOCTOU guard: every caller passes a `targetNode.file_path`
935
+ it already has from a live candidate-node list, i.e. a file that exists; the `?? -1` only
936
+ matters if it's deleted in the instant before this statSync. */
706
937
  const mtimeMs = statMtime(filePath) ?? -1;
707
938
  const cached = defaultExportCache.get(filePath);
708
939
  if (cached && cached.mtimeMs === mtimeMs)
@@ -714,12 +945,7 @@ function getDefaultExportName(filePath) {
714
945
  // `export default <expr>`
715
946
  if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
716
947
  const expr = stmt.expression;
717
- if (ts.isIdentifier(expr))
718
- name = expr.text;
719
- else if ((ts.isClassExpression(expr) || ts.isFunctionExpression(expr)) && expr.name)
720
- name = expr.name.text;
721
- else
722
- name = ANON_DEFAULT; // `export default Joi.object({...})`, `{...}`, `() => …`
948
+ name = ts.isIdentifier(expr) ? expr.text : anonOrNamedExpressionDefaultName(expr);
723
949
  break;
724
950
  }
725
951
  // `export default class X {}` / `export default function X() {}`
@@ -770,8 +996,7 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
770
996
  return [];
771
997
  }
772
998
  const fileContent = fs.readFileSync(sourceFilePath, 'utf-8');
773
- const ext = path.extname(sourceFilePath).toLowerCase();
774
- const isTsOrJs = ['.ts', '.tsx', '.js', '.jsx'].includes(ext);
999
+ const isTsOrJs = isAstParseable(sourceFilePath);
775
1000
  const tsPaths = loadTsPaths(repoRoot);
776
1001
  let referencedNames = new Set();
777
1002
  let thisMembers = new Set();
@@ -822,6 +1047,8 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
822
1047
  const paths = resolveImportToPaths(imp.moduleSpecifier, sourceDir, repoRoot, tsPaths);
823
1048
  const barrels = [];
824
1049
  for (const p of paths) {
1050
+ /* istanbul ignore if -- defensive: see the matching guard in resolveToExistingFile —
1051
+ resolveImportToPaths never pushes a falsy path. */
825
1052
  if (!p)
826
1053
  continue;
827
1054
  const rx = getBarrelReexports(p);
@@ -889,8 +1116,11 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
889
1116
  connections.add(targetNode.id);
890
1117
  continue;
891
1118
  }
1119
+ // Check the target's own name AND any alias it's exported under (e.g. an RTK-generated
1120
+ // hook name for its endpoint) — one implementation, several referenceable handles.
892
1121
  const nameToCheck = memberName || symbolName;
893
- if (referencedNames.has(nameToCheck)) {
1122
+ const targetNames = [nameToCheck, ...(targetNode.aliases ?? [])];
1123
+ if (targetNames.some(n => referencedNames.has(n))) {
894
1124
  connections.add(targetNode.id);
895
1125
  }
896
1126
  continue;
@@ -973,8 +1203,13 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
973
1203
  // matching a `Utils.formatDate` method) — a pure false positive.
974
1204
  }
975
1205
  else {
976
- // Top-level function/variable imported & referenced
977
- if (importedAsNames.includes(symbolName) && referencedNames.has(symbolName)) {
1206
+ // Top-level function/variable imported & referenced — check the target's own declared
1207
+ // name AND any alias it's exported under. This is the RTK fix: a caller importing
1208
+ // `useGetAdminOrdersQuery` (generated by createApi, never a name that appears in the
1209
+ // endpoint's own declaration) still resolves to the `getAdminOrders` endpoint node, as
1210
+ // long as that alias was attached to it (see the RTK detector / Phase C).
1211
+ const targetNames = [symbolName, ...(targetNode.aliases ?? [])];
1212
+ if (targetNames.some(n => importedAsNames.includes(n) && referencedNames.has(n))) {
978
1213
  connections.add(targetNode.id);
979
1214
  continue;
980
1215
  }
@@ -1007,10 +1242,15 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
1007
1242
  // default-imported and its alias is referenced here, and the file is small enough that
1008
1243
  // its default export is unambiguous, link it. The node-count gate keeps this from
1009
1244
  // re-exploding on large files (whose default export is virtually always named anyway).
1245
+ // `?? 99` is defensive only: `nodesPerFile` is built from `candidateNodes`, which always
1246
+ // includes `targetNode` itself, so its own file is always present with count >= 1; the
1247
+ // `?? 99` fallback can never actually be taken.
1248
+ /* istanbul ignore next */
1249
+ const targetFileNodeCount = nodesPerFile.get(normFile(targetNode.file_path)) ?? 99;
1010
1250
  if (isImported &&
1011
1251
  importedAsDefaultNames.some(alias => referencedNames.has(alias)) &&
1012
1252
  getDefaultExportName(targetNode.file_path) === ANON_DEFAULT &&
1013
- (nodesPerFile.get(normFile(targetNode.file_path)) ?? 99) <= 3) {
1253
+ targetFileNodeCount <= 3) {
1014
1254
  connections.add(targetNode.id);
1015
1255
  continue;
1016
1256
  }
@@ -1049,13 +1289,13 @@ function resolveConnectionsLocally(sourceNodeId, sourceFilePath, candidateNodes,
1049
1289
  return Array.from(connections);
1050
1290
  }
1051
1291
  /**
1052
- * Derive a node's identity/type/code directly from its declaration in a file deterministic,
1053
- * no LLM. Used by `--fill-missing` to create nodes that Phase-1 extraction skipped. Returns
1054
- * null when the file isn't TS/JS or the symbol can't be located.
1292
+ * Locate a symbol's span in a file via the AST. This is the write-side counterpart to
1293
+ * `extractNodeFromFile`: same lookup, but it also surfaces the offsets, so a caller can
1294
+ * replace the symbol without the caller ever having to read the file or reproduce its
1295
+ * text byte-exactly.
1055
1296
  */
1056
- function extractNodeFromFile(filePath, symbolName) {
1057
- const ext = path.extname(filePath).toLowerCase();
1058
- if (!['.ts', '.tsx', '.js', '.jsx'].includes(ext))
1297
+ function locateNodeInFile(filePath, symbolName) {
1298
+ if (!isAstParseable(filePath))
1059
1299
  return null;
1060
1300
  try {
1061
1301
  const sf = getSourceFile(filePath);
@@ -1064,16 +1304,396 @@ function extractNodeFromFile(filePath, symbolName) {
1064
1304
  const node = findNodeInAst(sf, className, symbolName);
1065
1305
  if (!node)
1066
1306
  return null;
1307
+ const start = node.getStart(sf); // skips leading trivia → a JSDoc block above stays put
1308
+ const end = node.getEnd();
1067
1309
  const code = node.getText(sf);
1310
+ const full = sf.getFullText();
1311
+ // Indent is only meaningful when nothing but whitespace precedes the symbol on its line;
1312
+ // for an inline symbol (`const x = function () {}`) there is no indent to re-apply.
1313
+ const lineStart = full.lastIndexOf('\n', start - 1) + 1;
1314
+ const prefix = full.slice(lineStart, start);
1068
1315
  return {
1069
1316
  name: parts[parts.length - 1] || symbolName,
1070
1317
  type: astBaseType(node),
1071
1318
  signature: code.split('\n')[0].slice(0, 200),
1072
- codeSnapshot: code
1319
+ codeSnapshot: code,
1320
+ start,
1321
+ end,
1322
+ startLine: sf.getLineAndCharacterOfPosition(start).line + 1,
1323
+ endLine: sf.getLineAndCharacterOfPosition(end).line + 1,
1324
+ indent: /^[ \t]*$/.test(prefix) ? prefix : ''
1073
1325
  };
1074
1326
  }
1075
1327
  catch {
1076
1328
  return null;
1077
1329
  }
1078
1330
  }
1331
+ function extractNodeFromFile(filePath, symbolName) {
1332
+ const loc = locateNodeInFile(filePath, symbolName);
1333
+ if (!loc)
1334
+ return null;
1335
+ const { name, type, signature, codeSnapshot } = loc;
1336
+ return { name, type, signature, codeSnapshot };
1337
+ }
1338
+ /**
1339
+ * The name a declaration contributes to a node id, or null if it isn't one the graph models.
1340
+ *
1341
+ * Mirrors the shapes `findNodeInAst` resolves in the opposite direction, including the
1342
+ * object-literal factory form (`Page({ onShopLook() {} })` → `Page.onShopLook`) used by
1343
+ * mini-program style frameworks.
1344
+ */
1345
+ function declarationNameOf(node) {
1346
+ const plain = (n) => n.name && (ts.isIdentifier(n.name) || ts.isStringLiteral(n.name)) ? n.name.text : null;
1347
+ // Nothing declared inside a function body is a graph entity: a local `const`, a helper
1348
+ // function, an object literal built in a return statement. Only declarations reachable from
1349
+ // the file without passing through a function qualify — which still admits class members and
1350
+ // factory-call members, since a class or a call is not a function body.
1351
+ for (let p = node.parent; p; p = p.parent) {
1352
+ if (ts.isSourceFile(p))
1353
+ break;
1354
+ if (isFunctionLikeScope(p))
1355
+ return null;
1356
+ }
1357
+ if (ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) ||
1358
+ ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) ||
1359
+ ts.isEnumDeclaration(node)) {
1360
+ const n = plain(node);
1361
+ return n ? { name: n, qualified: n } : null;
1362
+ }
1363
+ // Class members carry their class name. A method may also live in an object literal rather
1364
+ // than a class, so membership is decided by the parent, not by the node kind.
1365
+ if (ts.isMethodDeclaration(node) || ts.isPropertyDeclaration(node) ||
1366
+ ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) ||
1367
+ ts.isConstructorDeclaration(node) || ts.isPropertyAssignment(node) ||
1368
+ ts.isShorthandPropertyAssignment(node)) {
1369
+ const member = ts.isConstructorDeclaration(node) ? 'constructor' : plain(node);
1370
+ if (!member)
1371
+ return null;
1372
+ if (node.parent && ts.isClassLike(node.parent) && node.parent.name) {
1373
+ return { name: member, qualified: `${node.parent.name.text}.${member}` };
1374
+ }
1375
+ // A plain key is only a symbol when its object is itself named — `Page({ data: {...} })`
1376
+ // or `const api = { timeout: 30 }`. Keys nested deeper (`data: { n: 1 }` → `n`) are that
1377
+ // object's CONTENTS, not entities of their own: the graph models `Page.data`, never
1378
+ // `Page.n`. Methods are exempt — a function nested at any depth is still a real symbol.
1379
+ if (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) {
1380
+ /* istanbul ignore next -- defensive: by grammar, a PropertyAssignment/
1381
+ ShorthandPropertyAssignment's parent is always its containing ObjectLiteralExpression, so
1382
+ the `: undefined` (not-an-object-literal-parent) side of this ternary is unreachable. */
1383
+ const owner = node.parent && ts.isObjectLiteralExpression(node.parent) ? node.parent.parent : undefined;
1384
+ const namedObject = owner && (ts.isCallExpression(owner) || ts.isVariableDeclaration(owner));
1385
+ if (!namedObject)
1386
+ return null;
1387
+ }
1388
+ // Otherwise it sits inside an object literal. Walk out to whatever names that object:
1389
+ // a factory call (`Page({...})` → `Page.onShopLook`) or a variable (`const api = {...}`).
1390
+ for (let cur = node.parent; cur; cur = cur.parent) {
1391
+ if (ts.isCallExpression(cur) && ts.isIdentifier(cur.expression)) {
1392
+ return { name: member, qualified: `${cur.expression.text}.${member}` };
1393
+ }
1394
+ if (ts.isVariableDeclaration(cur) && cur.name && ts.isIdentifier(cur.name)) {
1395
+ return { name: member, qualified: `${cur.name.text}.${member}` };
1396
+ }
1397
+ if (ts.isClassLike(cur) && cur.name) {
1398
+ return { name: member, qualified: `${cur.name.text}.${member}` };
1399
+ }
1400
+ }
1401
+ return { name: member, qualified: member };
1402
+ }
1403
+ if (ts.isVariableDeclaration(node)) {
1404
+ const n = plain(node);
1405
+ return n ? { name: n, qualified: n } : null;
1406
+ }
1407
+ return null;
1408
+ }
1409
+ /** Every declaration whose span overlaps one of `ranges`, innermost-first ordering not implied. */
1410
+ function declarationsOverlapping(sf, ranges) {
1411
+ // A zero-width range (a pure deletion) still has to intersect something, so give it width 1.
1412
+ const spans = ranges.map(r => ({ start: r.start, end: Math.max(r.end, r.start + 1) }));
1413
+ const hits = [];
1414
+ const visit = (n) => {
1415
+ const start = n.getStart(sf);
1416
+ const end = n.getEnd();
1417
+ if (!spans.some(s => start < s.end && s.start < end))
1418
+ return; // disjoint — skip its subtree
1419
+ const named = declarationNameOf(n);
1420
+ if (named)
1421
+ hits.push({ node: n, name: named.name, qualified: named.qualified, start, end });
1422
+ ts.forEachChild(n, visit);
1423
+ };
1424
+ ts.forEachChild(sf, visit);
1425
+ return hits;
1426
+ }
1427
+ /**
1428
+ * Whether a declaration is part of the file's export surface — directly (`export function foo`,
1429
+ * `export const foo = ...`), via a separate `export { foo }` statement, or transitively (a method
1430
+ * of an exported class, a member of an object literal assigned to an exported variable). This is
1431
+ * the auto-accept signal for {@link enumerateFileCandidates}: an exported, named declaration is
1432
+ * unambiguously a real entity — nothing about its EXISTENCE is a judgment call, only an
1433
+ * unexported/anonymous one needs curation.
1434
+ */
1435
+ function isNodeExported(node, sourceFile) {
1436
+ // Class/object-literal members inherit their container's export status — a method isn't
1437
+ // separately "exported", it's reachable (or not) through whatever names its class/object.
1438
+ if (ts.isMethodDeclaration(node) || ts.isPropertyDeclaration(node) || ts.isGetAccessorDeclaration(node) ||
1439
+ ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node) ||
1440
+ ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) {
1441
+ if (node.parent && ts.isClassLike(node.parent))
1442
+ return isNodeExported(node.parent, sourceFile);
1443
+ for (let cur = node.parent; cur; cur = cur.parent) {
1444
+ if (ts.isVariableStatement(cur))
1445
+ return isNodeExported(cur, sourceFile);
1446
+ if (ts.isSourceFile(cur))
1447
+ break;
1448
+ }
1449
+ return false;
1450
+ }
1451
+ const hasExportModifier = (n) => !!n.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
1452
+ // `export const foo = ...`: the `export` modifier sits on the enclosing VariableStatement, not
1453
+ // on the VariableDeclaration itself.
1454
+ const modifierHost = ts.isVariableDeclaration(node) && node.parent && ts.isVariableDeclarationList(node.parent)
1455
+ ? node.parent.parent
1456
+ : node;
1457
+ if (hasExportModifier(modifierHost))
1458
+ return true;
1459
+ // `export { foo, bar }` as its own statement elsewhere in the file, not a modifier at all.
1460
+ const declaredName = (ts.isVariableDeclaration(node) && node.name && ts.isIdentifier(node.name)) ? node.name.text :
1461
+ (node.name && ts.isIdentifier(node.name))
1462
+ ? node.name.text
1463
+ : null;
1464
+ if (!declaredName)
1465
+ return false;
1466
+ for (const stmt of sourceFile.statements) {
1467
+ if (ts.isExportDeclaration(stmt) && !stmt.moduleSpecifier &&
1468
+ stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
1469
+ if (stmt.exportClause.elements.some(el => el.name.text === declaredName || el.propertyName?.text === declaredName)) {
1470
+ return true;
1471
+ }
1472
+ }
1473
+ }
1474
+ return false;
1475
+ }
1476
+ /**
1477
+ * Enumerates every graph-eligible declaration in a file, deterministically — no LLM, so nothing
1478
+ * gets silently missed the way the old whole-file-to-an-LLM extraction could. A named, EXPORTED
1479
+ * declaration (`isExported:true`) is unambiguous: whether it "counts" as a node requires no
1480
+ * judgment, so the caller can auto-accept it with zero LLM turns. Everything else — unexported
1481
+ * helpers, anonymous default exports, tiny inline callbacks, object-literal factory members whose
1482
+ * significance is genuinely a judgment call — is still enumerated (nothing is dropped silently)
1483
+ * but flagged `isExported:false`, so an agentic curation pass only has to spend turns on THOSE.
1484
+ */
1485
+ function enumerateFileCandidates(filePath) {
1486
+ if (!isAstParseable(filePath))
1487
+ return [];
1488
+ let sourceFile;
1489
+ try {
1490
+ sourceFile = getSourceFile(filePath);
1491
+ }
1492
+ catch {
1493
+ return [];
1494
+ }
1495
+ const fullText = sourceFile.getFullText();
1496
+ const hits = declarationsOverlapping(sourceFile, [{ start: 0, end: fullText.length }]);
1497
+ // A single declaration can surface more than once under different matching rules (e.g. a
1498
+ // property assignment's own span, and — if it were itself a match — an initializer nested
1499
+ // inside it). Keep the FIRST hit per qualified name: `declarationsOverlapping` walks
1500
+ // parent-before-child, so the first hit is always the outermost, correct one.
1501
+ const byQualified = new Map();
1502
+ for (const h of hits) {
1503
+ if (!byQualified.has(h.qualified))
1504
+ byQualified.set(h.qualified, h);
1505
+ }
1506
+ const results = [];
1507
+ for (const h of byQualified.values()) {
1508
+ const code = h.node.getText(sourceFile);
1509
+ results.push({
1510
+ qualified: h.qualified,
1511
+ name: h.name,
1512
+ type: astBaseType(h.node),
1513
+ signature: code.split('\n')[0].slice(0, 200),
1514
+ codeSnapshot: code,
1515
+ startLine: sourceFile.getLineAndCharacterOfPosition(h.start).line + 1,
1516
+ endLine: sourceFile.getLineAndCharacterOfPosition(h.end).line + 1,
1517
+ isExported: isNodeExported(h.node, sourceFile)
1518
+ });
1519
+ }
1520
+ // RTK Query endpoints live inside a function body (`endpoints: (builder) => ({...})`), so the
1521
+ // general declaration walk above structurally cannot see them — see `findRtkEndpointMatches`.
1522
+ // Folded in here (not called separately by every consumer) so `enumerateFileCandidates` really
1523
+ // is the complete candidate list for a file, RTK or not.
1524
+ for (const rtkNode of detectRtkEndpointNodes(filePath)) {
1525
+ if (!byQualified.has(rtkNode.qualified))
1526
+ results.push(rtkNode);
1527
+ }
1528
+ return results;
1529
+ }
1530
+ /**
1531
+ * Every OTHER top-level declaration in a file — functions, classes, consts, types, interfaces —
1532
+ * the "what else is here" a raw file read used to be the only way to answer. Reuses
1533
+ * {@link enumerateFileCandidates}'s own declaration walk (`declarationsOverlapping` /
1534
+ * `astBaseType` / `isNodeExported`), but deliberately does NOT call `node.getText()` per hit the
1535
+ * way that function does: `enumerateFileCandidates` materializes each declaration's FULL source
1536
+ * just to build a one-line signature and then discards it, which is an extra copy of potentially
1537
+ * the whole file in string allocations — on every single `get_node_code` call, this is squarely
1538
+ * the wrong trade. A short slice of the already-loaded full text plus a line split gets the same
1539
+ * signature far cheaper. The parse itself is free either way: `getSourceFile` is mtime-cached
1540
+ * ({@link getSourceFile}), and by the time `get_node_code` calls this the file has usually already
1541
+ * been parsed once this same request via `getLiveCode`/`extractLiveCode`.
1542
+ */
1543
+ function outlineFile(filePath) {
1544
+ if (!isAstParseable(filePath))
1545
+ return [];
1546
+ let sourceFile;
1547
+ try {
1548
+ sourceFile = getSourceFile(filePath);
1549
+ }
1550
+ catch {
1551
+ return [];
1552
+ }
1553
+ const fullText = sourceFile.getFullText();
1554
+ const hits = declarationsOverlapping(sourceFile, [{ start: 0, end: fullText.length }]);
1555
+ // Same "keep the first (outermost) hit per qualified name" rule as enumerateFileCandidates —
1556
+ // declarationsOverlapping walks parent-before-child, so the first hit is always correct.
1557
+ const byQualified = new Map();
1558
+ for (const h of hits) {
1559
+ if (!byQualified.has(h.qualified))
1560
+ byQualified.set(h.qualified, h);
1561
+ }
1562
+ const results = [];
1563
+ for (const h of byQualified.values()) {
1564
+ /* istanbul ignore next -- the `|| null` arm is unreachable: `declarationsOverlapping` takes
1565
+ each hit's start from `node.getStart(sf)`, which skips leading trivia, so the slice always
1566
+ begins at a real token character and its first line is never the empty string. Kept as a
1567
+ type-level guarantee that `signature` is `string | null` rather than `string | undefined`. */
1568
+ const signature = fullText.slice(h.start, h.start + 200).split('\n')[0] || null;
1569
+ results.push({
1570
+ qualified: h.qualified,
1571
+ name: h.name,
1572
+ type: astBaseType(h.node),
1573
+ signature,
1574
+ start_line: sourceFile.getLineAndCharacterOfPosition(h.start).line + 1,
1575
+ end_line: sourceFile.getLineAndCharacterOfPosition(h.end).line + 1,
1576
+ exported: isNodeExported(h.node, sourceFile)
1577
+ });
1578
+ }
1579
+ // RTK Query endpoints live inside a function body and the general walk above structurally
1580
+ // cannot see them (see detectRtkEndpointNodes) — folded in here so the outline is genuinely
1581
+ // complete, RTK or not, same as enumerateFileCandidates. Its own codeSnapshot is dropped; these
1582
+ // are individual endpoint bodies (small), not a whole-file cost, but the outline never carries
1583
+ // code regardless of source.
1584
+ for (const rtk of detectRtkEndpointNodes(filePath)) {
1585
+ if (byQualified.has(rtk.qualified))
1586
+ continue;
1587
+ results.push({
1588
+ qualified: rtk.qualified,
1589
+ name: rtk.name,
1590
+ type: rtk.type,
1591
+ signature: rtk.signature,
1592
+ start_line: rtk.startLine,
1593
+ end_line: rtk.endLine,
1594
+ exported: rtk.isExported
1595
+ });
1596
+ }
1597
+ return results;
1598
+ }
1599
+ /**
1600
+ * A file's own import list — file-path entry point for {@link getFileImports} (which needs an
1601
+ * already-parsed `ts.SourceFile`). Used by the Phase D curation agent's `get_file_imports` tool:
1602
+ * whether an ambiguous candidate is worth keeping is often decided by whether it's ever imported
1603
+ * anywhere, and the file's OWN imports are the cheap first signal (e.g. a candidate that mirrors
1604
+ * a re-exported name, or that only makes sense alongside a specific imported dependency).
1605
+ */
1606
+ function listFileImports(filePath) {
1607
+ if (!isAstParseable(filePath))
1608
+ return [];
1609
+ try {
1610
+ return getFileImports(getSourceFile(filePath));
1611
+ }
1612
+ catch {
1613
+ return [];
1614
+ }
1615
+ }
1616
+ /** A symbol's current source in `sf`, or null when it isn't there. */
1617
+ function codeOfSymbol(sf, symbolName) {
1618
+ const parts = symbolName.split('.');
1619
+ const className = parts.length === 2 ? parts[0] : undefined;
1620
+ const node = findNodeInAst(sf, className, symbolName);
1621
+ return node ? node.getText(sf) : null;
1622
+ }
1623
+ /**
1624
+ * Work out which symbols an edit actually changed, from where the edit landed.
1625
+ *
1626
+ * Position beats name matching: a span is unambiguous where a name is not (fifty classes can
1627
+ * each have a `run`), it survives the symbol being renamed by the very edit being traced, and
1628
+ * it finds code that did not exist until this write.
1629
+ *
1630
+ * Three passes, each removing a specific kind of wrong answer:
1631
+ * 1. OVERLAP — every declaration intersecting the written span. A point would not do: text
1632
+ * appended after an anchor (`}` → `}\n\nfunction added() {}`) begins inside the PREVIOUS
1633
+ * function, so a point attributes the new function to its neighbour.
1634
+ * 2. INNERMOST — drop any declaration that contains another hit, so inserting a method
1635
+ * reports the method and not its whole class.
1636
+ * 3. CHANGED — compare each survivor against `beforeContent` and keep only what actually
1637
+ * differs. Overlap alone over-reports: the anchor's own function is intersected by an
1638
+ * append yet is untouched by it, and re-recording it would invent history for a change
1639
+ * that never happened. A symbol absent from `beforeContent` is new.
1640
+ *
1641
+ * Symbols already in the graph are matched by span against their RECORDED ids, never by
1642
+ * re-deriving a name — a container name may have been chosen at index time and be absent from
1643
+ * the source (`Component({...})` recorded as `ProductImageGalleryComponent`).
1644
+ *
1645
+ * `knownSymbols` should be the graph's nodes for this file; empty is fine, everything is then
1646
+ * reported as new. An edit touching no symbol at all (an import, markup, a config value) yields
1647
+ * nothing — a normal outcome, not an error.
1648
+ */
1649
+ function findTouchedSymbols(filePath, ranges, knownSymbols = [], beforeContent) {
1650
+ if (!isAstParseable(filePath) || !ranges.length)
1651
+ return [];
1652
+ try {
1653
+ const sf = getSourceFile(filePath);
1654
+ const before = beforeContent === undefined ? null : parseText(filePath, beforeContent);
1655
+ // 1. overlap
1656
+ const hits = declarationsOverlapping(sf, ranges);
1657
+ if (!hits.length)
1658
+ return [];
1659
+ // Prefer the graph's own id wherever a hit is the same span as a node it already knows.
1660
+ const byId = new Map();
1661
+ for (const k of knownSymbols) {
1662
+ const loc = locateNodeInFile(filePath, k.symbolName);
1663
+ if (loc)
1664
+ byId.set(`${loc.start}-${loc.end}`, k);
1665
+ }
1666
+ // 2. innermost: discard anything that strictly contains another hit
1667
+ const innermost = hits.filter(h => !hits.some(o => o !== h && o.start >= h.start && o.end <= h.end && (o.end - o.start) < (h.end - h.start)));
1668
+ // 3. changed-only
1669
+ const out = new Map();
1670
+ for (const h of innermost) {
1671
+ const known = byId.get(`${h.start}-${h.end}`);
1672
+ const symbolName = known ? known.symbolName : h.qualified;
1673
+ const newCode = h.node.getText(sf);
1674
+ const oldCode = before ? codeOfSymbol(before, symbolName) : null;
1675
+ if (oldCode !== null && oldCode === newCode)
1676
+ continue; // intersected but untouched
1677
+ const key = known ? known.id : symbolName;
1678
+ if (out.has(key))
1679
+ continue;
1680
+ out.set(key, {
1681
+ node_id: known?.id,
1682
+ symbolName,
1683
+ name: h.name,
1684
+ type: astBaseType(h.node),
1685
+ signature: newCode.split('\n')[0].slice(0, 200),
1686
+ codeSnapshot: newCode,
1687
+ codeBefore: oldCode,
1688
+ startLine: sf.getLineAndCharacterOfPosition(h.start).line + 1,
1689
+ endLine: sf.getLineAndCharacterOfPosition(h.end).line + 1,
1690
+ isNew: !known
1691
+ });
1692
+ }
1693
+ return Array.from(out.values());
1694
+ }
1695
+ catch {
1696
+ return [];
1697
+ }
1698
+ }
1079
1699
  //# sourceMappingURL=ast.js.map