fix(ts/js): scope a variable's initializer walk to the variable (#693 for TS/JS)

Two defects in one place — extractVariable's JS-family branch.

1. The initializer walk ran with only the FILE on the node stack, so
   `const cfg = loadConfig()` recorded the FILE as loadConfig's caller. That is
   literally the leak #693 described and #744 fixed for Go; the closing note
   there said "JS/TS didn't have this gap", which holds only for the
   `const f = () => …` shape (an arrow value delegates to extractFunction and
   gets its own node).

2. Object literals were excluded from the walk outright, on the grounds that
   their function-valued members are extracted individually below — but that
   only happens for EXPORTED consts. `const obj = { handler: () => target() }`
   therefore contributed nothing at all: no member node, and no call edge to
   anything.

Fix: walk the initializer with the declared symbol pushed on the stack, and
skip only the shapes whose members really are extracted one-by-one (exported
object-of-functions, RTK endpoints, Pinia setup, Vue store collections) —
walking those too would double-count each member arrow's calls.

Two follow-on repairs the change surfaced:

- CFML `<cfscript>` bodies are delegated to a separate extractor as if they
  were a whole module, so a `var x = helper()` local inside a `<cffunction>`
  minted a top-level variable node and the walk attributed `helper` to it.
  Those snippet-top-level non-callables are locals of the enclosing function;
  their refs now redirect to it, like the snippet's file-attributed refs
  already did.
- codegraph_explore stopped listing a synthesized dynamic-dispatch link once
  the same pair also had a static call edge: it asked getCallers/getCallees,
  which return one row per NEIGHBOUR (the #1086 de-dup), so the static edge
  hid the heuristic one. It now asks the edges directly. This is exactly the
  RTK thunk case — `dispatch(innerThunk(n))` inside a thunk initializer is now
  a real static edge, which is the point, but the synthesized hop must still
  show up in the summary.

Measured on three independent TypeScript trees (codegraph's own src/, evcc-ng,
gv-grx — 499 files): 0 lost nodes/edges/refs, 780 refs re-attributed from the
file node to the declaring constant, 228 genuinely new refs from object
literals, node and edge counts unchanged. Kernel parity holds (324/324
non-deferred files byte-identical).
This commit is contained in:
danusha2345
2026-08-05 22:44:33 +03:00
parent 468f623745
commit a10a7fa6f7
7 changed files with 113 additions and 27 deletions
+15 -1
View File
@@ -356,6 +356,13 @@ export class CfmlExtractor {
.filter((e) => e.kind === 'contains' && e.source === innerFileNodeId)
.map((e) => e.target)
);
// Snippet-top-level non-callables: `var x = …` locals of the enclosing
// function that the fragment-as-module parse mints as declarations.
const localVarIds = new Set(
result.nodes
.filter((n) => topLevelIds.has(n.id) && (n.kind === 'variable' || n.kind === 'constant'))
.map((n) => n.id)
);
for (const node of result.nodes) {
if (node.kind === 'file') continue;
node.startLine += startLine;
@@ -385,7 +392,14 @@ export class CfmlExtractor {
// top-level script in a .cfm template, or any statement directly in
// the snippet body) attribute to the filtered-out snippet file node by
// default — redirect those (and any genuinely unset ones) to parentId.
if ((!ref.fromNodeId || ref.fromNodeId === innerFileNodeId) && parentId) ref.fromNodeId = parentId;
// Same for a snippet-top-level `var x = helper()`: the inner extractor
// parses the fragment as a whole module, so it mints a variable node and
// attributes the initializer's calls to it — but this fragment is a
// FUNCTION BODY, so `x` is a local and `helper` is the enclosing
// function's callee. Snippet-top-level FUNCTIONS keep their own calls.
if ((!ref.fromNodeId || ref.fromNodeId === innerFileNodeId || localVarIds.has(ref.fromNodeId)) && parentId) {
ref.fromNodeId = parentId;
}
this.unresolvedReferences.push(ref);
}
for (const error of result.errors) {
+17 -12
View File
@@ -2697,19 +2697,24 @@ export class TreeSitterExtractor {
storeCollections.push(objectOfFns);
}
// Visit the initializer body for calls — EXCEPT object literals (their
// function-valued properties are extracted below) and the store-factory
// / createApi / store-collection call whose nested objects we extract
// method-by-method below (walking the whole call would re-visit those
// method arrows and mis-attribute their inner calls to the file scope).
if (valueNode &&
valueNode.type !== 'object' &&
valueNode.type !== 'object_expression' &&
!(extractObjectMethods && valueNode.type === 'call_expression') &&
!rtkEndpoints &&
!piniaSetup &&
storeCollections.length === 0) {
// Visit the initializer body for calls, ATTRIBUTED to the declared
// symbol (#693) — EXCEPT the shapes whose members are extracted
// one-by-one below (the store-factory / createApi / store-collection
// objects), where walking the whole initializer would re-visit each
// member arrow and double-count its calls.
//
// Two things were wrong here before. The walk ran with only the FILE
// on the stack, so `const cfg = load()` recorded the FILE as load's
// caller — the exact leak Go's #693 fixed. And an object literal was
// skipped outright, so `const obj = { handler: () => target() }`
// contributed nothing at all unless the const was exported (only then
// does extractObjectLiteralFunctions mint the members).
const membersExtractedSeparately =
extractObjectMethods || !!rtkEndpoints || !!piniaSetup || storeCollections.length > 0;
if (valueNode && !membersExtractedSeparately) {
if (varNode) this.nodeStack.push(varNode.id);
this.visitFunctionBody(valueNode, '');
if (varNode) this.nodeStack.pop();
}
if (extractObjectMethods && objectOfFns) {
+18 -3
View File
@@ -2383,8 +2383,16 @@ export class ToolHandler {
// fed only to the dynamic-dispatch-links scan below.
const dynNamed = new Map<string, Node>();
const DYN_KINDS = new Set(['constant', 'variable', 'field', 'property']);
// RAW edges, not getCallers/getCallees: those return one row per NEIGHBOUR
// (the #1086 de-dup), so when a pair is joined by BOTH a static and a
// synthesized edge the static one wins and the synthesized one becomes
// invisible — which is exactly what happens once a thunk's `dispatch(x)`
// is walked statically. The question here is about the graph, not about
// callers, so ask the edges directly.
const hasHeuristicEdge = (id: string): boolean =>
[...cg.getCallers(id), ...cg.getCallees(id)].some(({ edge }) => edge.provenance === 'heuristic');
[...cg.getIncomingEdges(id), ...cg.getOutgoingEdges(id)].some(
(e) => e.provenance === 'heuristic'
);
for (const t of tokens) {
const hits = this.findAllSymbols(cg, t).nodes;
const cands = hits.filter((n) => CALLABLE.has(n.kind));
@@ -2432,9 +2440,16 @@ export class ToolHandler {
const synthSeen = new Set<string>();
for (const n of [...named.values(), ...dynNamed.values()]) {
if (synthLines.length >= 6) break;
for (const { node: other, edge } of [...cg.getCallers(n.id), ...cg.getCallees(n.id)]) {
// RAW edges for the same reason as hasHeuristicEdge above — a static
// edge over the same pair hides the synthesized one from getCallers.
const incident = [...cg.getIncomingEdges(n.id), ...cg.getOutgoingEdges(n.id)];
for (const edge of incident) {
if (synthLines.length >= 6) break;
if (edge.provenance !== 'heuristic' || other.id === n.id) continue;
if (edge.provenance !== 'heuristic') continue;
const otherId = edge.source === n.id ? edge.target : edge.source;
if (otherId === n.id) continue;
const other = cg.getNode(otherId);
if (!other) continue;
if (skipInChain && skipInChain(edge)) continue;
const src = edge.source === n.id ? n : other;
const tgt = edge.source === n.id ? other : n;