fix(extraction): a function in an initializer is referenced once, by what the initializer declares (#2743)

PRs #693 and #1802 walk a declaration's initializer under the symbol it declares,
so `const routes = { home }` references `home` from `routes`. The
dispatcher's declaration-wide function-as-value scan then took the same
values again for the enclosing file (or class). Flush-time dedup keys on
the source, so both stayed: a second reference to the same function from
the same spot.

Each initializer walk now records what it walked, and the scan skips it.
PR #2738 added that mechanism for component wrappers, and C#, Scala and Dart
already had their own. The newly covered paths:
- TS/JS module-level constants and variables, owned object literals
  included (#2300), and TS/JS class properties;
- Python assignments, Go var/const specs, Rust const/static items;
- Java fields, and Kotlin properties (walkInitializer takes the property's
  id, so their walk is unchanged).
Mirrored in the kernel: tsjs, python, go, rustlang, java and kotlin.

References no initializer walk takes are unchanged: a module-level call
argument `register(fn)`, C-family tables, Rust's `static CB: fn() = handler`
(the declaration's own value, which the scan still reads).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-21 11:41:45 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ae0fc631e0
commit 51be28d15b
17 changed files with 233 additions and 79 deletions
+6 -5
View File
@@ -293,12 +293,13 @@ export const kotlinExtractor: LanguageExtractor = {
// grammar makes a following SIBLING rather than a child; those bodies used
// to attribute to the enclosing class. Consumed here so the accessor
// branch below can skip them without any cross-node state.
const inits = created
? [...kotlinPropertyInitializers(node), ...kotlinFollowingAccessors(node)]
: [];
if (created && inits.length > 0) {
// The initializers are walked as such, so the dispatcher's
// function-as-value scan of the property skips them: it would take each
// function value again for the enclosing file or class.
if (created) {
ctx.pushScope(created.id);
for (const init of inits) ctx.visitFunctionBody(init, created.id);
for (const init of kotlinPropertyInitializers(node)) ctx.walkInitializer(init, created.id);
for (const accessor of kotlinFollowingAccessors(node)) ctx.visitFunctionBody(accessor, created.id);
ctx.popScope();
}
return true;
+5 -4
View File
@@ -56,11 +56,12 @@ export interface ExtractorContext {
visitFunctionBody(body: SyntaxNode, functionId: string): void;
/**
* Walk a declaration's initializer as code the scope on top of the stack
* runs, the way a function body is walked. When the hook then reports the
* node handled, the function-as-value scan the dispatcher runs over it skips
* what was walked here: the walk captured those candidates already.
* runs, the way a function body is walked (`functionId` as for
* visitFunctionBody). When the hook then reports the node handled, the
* function-as-value scan the dispatcher runs over it skips what was walked
* here: the walk captured those candidates already.
*/
walkInitializer(node: SyntaxNode): void;
walkInitializer(node: SyntaxNode, functionId?: string): void;
/** Add an unresolved reference */
addUnresolvedReference(ref: UnresolvedReference): void;
/** Push a node ID onto the scope stack (for containment/qualified name building) */
+36 -24
View File
@@ -1521,9 +1521,12 @@ export class TreeSitterExtractor {
this.nodeStack.pop();
}
// A field initializer can also register callbacks
// (`static handlers = { click: onClick }`) — scan it for
// function-as-value candidates (capture-only, halts at functions).
this.scanFnRefSubtree(node, 0);
// (`static handlers = { click: onClick }`). The walk above took those
// for the property, so the scan for function-as-value candidates
// (capture-only, halts at functions) skips the initializer it walked
// and covers the rest of the field: the class would keep a second
// reference to each function from the same spot.
this.scanFnRefSubtree(node, 0, propNode && valueNode ? new Set([valueNode.id]) : undefined);
skipChildren = true;
} else {
this.extractMethod(node);
@@ -1616,16 +1619,18 @@ export class TreeSitterExtractor {
const definedType = declaredType && isClassLikeDefinition(declaredType) ? declaredType : null;
if (definedType) this.visitNode(definedType);
const walked = this.extractVariable(node);
// extractVariable doesn't walk every initializer shape (object literals
// are deliberately skipped; Python/Ruby don't walk at all), so scan the
// declaration subtree for function-as-value candidates — `const routes =
// { home: renderHome }`, `handlers = {"recv": target_cb}`. The scan halts
// at nested function definitions (their bodies are walked — and
// attributed — separately) and flush-time dedup absorbs any overlap with
// initializers extractVariable DOES walk. A type walked above, and a
// component wrapper's initializer extractVariable walked under the
// component (which must not hand the component it wraps to the file),
// captured their own.
// extractVariable doesn't walk every initializer (a store's members are
// extracted one by one, and only TS/JS, Python, Go and Rust walk one at
// all), so scan the declaration subtree for function-as-value candidates
// — `const routes = { home: renderHome }`, `handlers = {"recv":
// target_cb}`. The scan halts at nested function definitions (their
// bodies are walked — and attributed — separately). It skips what was
// walked already: a type walked above, and every initializer
// extractVariable walked under the symbol it declares — a constant's, a
// component wrapper's (which must not hand the component it wraps to the
// file). Those captured their own, and flush-time dedup keys on the
// source, so the file would keep a second reference to each function
// from the same spot.
this.scanFnRefSubtree(node, 0, definedType ? new Set([definedType.id]) : walked);
skipChildren = true; // extractVariable handles children
}
@@ -2016,9 +2021,9 @@ export class TreeSitterExtractor {
createNode: (kind, name, node, extra) => self.createNode(kind, name, node, extra),
visitNode: (node) => self.visitNode(node),
visitFunctionBody: (body, functionId) => self.visitFunctionBody(body, functionId),
walkInitializer: (node) => {
walkInitializer: (node, functionId = '') => {
(ctx.walked ??= new Set()).add(node.id);
self.visitFunctionBody(node, '');
self.visitFunctionBody(node, functionId);
},
addUnresolvedReference: (ref) => self.unresolvedReferences.push(ref),
pushScope: (nodeId) => self.nodeStack.push(nodeId),
@@ -2869,8 +2874,8 @@ export class TreeSitterExtractor {
/**
* Extract a class field declaration (e.g. Java field_declaration, C# field_declaration).
* Extracts each declarator as a 'field' kind node inside the owning class.
* Returns the C# declarators it walked, for the function-as-value scan to
* skip.
* Returns the initializers it walked (a C# declarator, a Java `value`), for
* the function-as-value scan to skip.
*/
private extractField(node: SyntaxNode): Set<number> {
const walked = new Set<number>();
@@ -2988,11 +2993,11 @@ export class TreeSitterExtractor {
decl.namedChild(decl.namedChildCount - 1),
varDecl ? getChildByField(varDecl, 'type') : null,
);
// Its function-as-value candidates are the field's, captured
// here once. (VB.NET captures none; Java's scan still takes
// its initializers for the class as well.)
walked.add(decl.id);
}
// Its function-as-value candidates are the field's, captured here
// once (VB.NET captures none): the scan would take Java's again for
// the class.
walked.add(valueNode.id);
this.visitFunctionBody(valueNode, fieldNode.id);
this.nodeStack.pop();
}
@@ -3584,9 +3589,11 @@ export class TreeSitterExtractor {
* Extracts top-level and module-level variable declarations.
* Captures the variable name and first 100 chars of initializer in signature for searchability.
*
* Returns the initializers it walked under a component wrapper, whose
* function-as-value candidates that walk already took (the caller's scan of
* the declaration skips them).
* Returns the initializers it walked under the symbol they declare — a
* constant's or a variable's (#693), a component wrapper's — whose
* function-as-value candidates that walk already took for the symbol. The
* caller's scan of the declaration skips them: it would take each again for
* the file, as a second reference to the same function from the same spot.
*/
private extractVariable(node: SyntaxNode): Set<number> | undefined {
if (!this.extractor) return undefined;
@@ -3686,6 +3693,7 @@ export class TreeSitterExtractor {
const ownedObject = varNode ? this.ownedObjectValue(valueNode) : null;
if (varNode && ownedObject) {
this.extractOwnedObjectMembers(ownedObject, varNode.id, true, false);
(walked ??= new Set()).add(ownedObject.id);
continue;
}
@@ -3772,6 +3780,7 @@ export class TreeSitterExtractor {
if (varNode) this.nodeStack.push(varNode.id);
this.visitFunctionBody(valueNode, '');
if (varNode) this.nodeStack.pop();
if (varNode) (walked ??= new Set()).add(valueNode.id);
}
if (extractObjectMethods && objectOfFns) {
@@ -3820,6 +3829,7 @@ export class TreeSitterExtractor {
if (assigned) this.nodeStack.push(assigned.id);
this.visitFunctionBody(right, '');
if (assigned) this.nodeStack.pop();
if (assigned) (walked ??= new Set()).add(right.id);
}
} else if (this.language === 'go') {
// Go: var_declaration, short_var_declaration, const_declaration
@@ -3860,6 +3870,7 @@ export class TreeSitterExtractor {
if (varNode) this.nodeStack.push(varNode.id);
this.visitFunctionBody(valueField, varNode?.id ?? '');
if (varNode) this.nodeStack.pop();
if (varNode) (walked ??= new Set()).add(valueField.id);
}
}
@@ -4016,6 +4027,7 @@ export class TreeSitterExtractor {
if (declared) this.nodeStack.push(declared.id);
this.visitFunctionBody(valueNode, '');
if (declared) this.nodeStack.pop();
if (declared) (walked ??= new Set()).add(valueNode.id);
}
}
}