mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-27 21:55:14 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user