From 5645d6592c9dc4267e959e4410bea911c7655f57 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 26 Jul 2026 05:56:27 +0000 Subject: [PATCH] fix(ingestion): class-field closures are callable members in TS/JS (#2693) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CALLS edge must target a callable node. `class A { handler = (x) => x }` emitted a Property, so calling it produced `CALLS -> Property:A.ts:A.handler` — an edge pointing at something the graph says is not callable. Same defect class as the JS/TS `var` binding fixed in the previous commit, and the last place a closure binding still carried a value label. Kotlin already models its class-body closure as Method + HAS_METHOD; TS/JS now match, so all three agree: class-field closure -> Method + HAS_METHOD (CALLS target is callable) plain class field -> Property + HAS_PROPERTY (unchanged, no CALLS) Anchored on public_field_definition / field_definition — the same nodes the property rules use — so the parse-worker dedup collapses the pair rather than leaving a Method/Property twin, the failure the Java rule hit in the previous commit. ON MATCHING THE COMPILERS. This deliberately diverges from tsc and SCIP. The TypeScript compiler classes `handler = () => {}` as a PropertyDeclaration ("a property declaration independently from what it's assigned to"), and SCIP gives it a `.` term descriptor, the same suffix as any field — both call it a property, and Kotlin's compiler likewise treats `val f = { }` as a property with a function type. The divergence is intentional: GitNexus's Function/Method label does not mean "tsc SymbolFlags", it means "this node can be the target of a CALLS edge", which is the convention #2687 set for closure bindings in every language. Modelling it the compiler's way would mean either dropping call resolution for these members or emitting a separate node for the lambda and flowing the property to it — the two-node shape #2687 removed. Recorded here so the next reader does not "fix" it back. Tests: TS and JS class-field arrows resolve to their Method node, plus a guard that a NON-closure class field stays a Property — the closure rule must key on the initializer, not on the field syntax. --- .../src/core/ingestion/tree-sitter-queries.ts | 29 ++++++++++++++++ .../closure-binding-labels.test.ts | 34 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 2375ab37f..51504853b 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -342,6 +342,25 @@ export const TYPESCRIPT_QUERIES = ` (public_field_definition name: (private_property_identifier) @name) @definition.property +; Closure-valued class fields (#2693): \`handler = (x) => x\` is a CALLABLE +; member, so it emits Method like every other closure binding rather than a +; Property that CALLS edges would point at — a call target must be callable. +; Kotlin already models its class-body closure this way (Method + HAS_METHOD). +; +; Note this diverges from tsc's SymbolFlags and SCIP's descriptor, which both +; class an arrow-initialized field as a PROPERTY/term. That is deliberate: the +; label here means "is a call target", not "is a tsc symbol kind", and #2687 set +; that convention for closure bindings in every language. Anchored on +; public_field_definition — the same node the property rules use — so the +; parse-worker dedup collapses the pair (callable ranks highest). +(public_field_definition + name: (property_identifier) @name + value: (arrow_function)) @definition.method + +(public_field_definition + name: (property_identifier) @name + value: (function_expression)) @definition.method + ; Constructor parameter properties: constructor(public address: Address) (required_parameter (accessibility_modifier) @@ -667,6 +686,16 @@ export const JAVASCRIPT_QUERIES = ` (field_definition property: (property_identifier) @name) @definition.property +; Closure-valued class fields (#2693) — see the TypeScript block for why these +; are Method rather than Property. +(field_definition + property: (property_identifier) @name + value: (arrow_function)) @definition.method + +(field_definition + property: (property_identifier) @name + value: (function_expression)) @definition.method + ; Write access: obj.field = value (assignment_expression left: (member_expression diff --git a/gitnexus/test/integration/closure-binding-labels.test.ts b/gitnexus/test/integration/closure-binding-labels.test.ts index 4dca786d9..bbad6f8cc 100644 --- a/gitnexus/test/integration/closure-binding-labels.test.ts +++ b/gitnexus/test/integration/closure-binding-labels.test.ts @@ -116,6 +116,14 @@ describe('closure bindings emit a single Function node in every language', () => ]); }); + it('TypeScript: a NON-closure class field stays a Property', async () => { + // The closure rule must key on the initializer, not the field syntax — + // otherwise every class field would become a callable member. + expect( + await labelsFor('src/plain.ts', 'export class A {\n address = "x";\n}\n', 'address'), + ).toEqual(['Property']); + }); + it('Python: an annotated attribute stays a Property, not a Variable', async () => { // Regression guard. Python matches BOTH `@definition.property` (annotated) // and `@definition.variable` (bare assignment) on the same statement at the @@ -405,6 +413,32 @@ describeIfWorkerBuilt('closure bindings resolve in the remaining languages (#269 expect(targets).toEqual(['Function:b.php:handler']); }); + it('TypeScript: a class-field arrow is a callable member, like Kotlin', async () => { + // A CALLS edge must target a CALLABLE node. This field used to emit + // Property, so the edge pointed at a non-callable — the same defect class + // as the `var` case below. Kotlin already modelled its class-body closure + // as Method + HAS_METHOD. + // + // This diverges from tsc (PropertyDeclaration) and SCIP (a `.` term), both + // of which class an arrow-initialised field as a property. Deliberate: the + // label means "is a call target" here, not "is a tsc symbol kind". + const targets = await callTargetsFor( + 'Box.ts', + 'export class Box {\n handler = (x: number) => x;\n caller(): number { return this.handler(1); }\n}\n', + ); + + expect(targets).toEqual(['Method:Box.ts:Box.handler']); + }); + + it('JavaScript: a class-field arrow is a callable member', async () => { + const targets = await callTargetsFor( + 'C.js', + 'export class C {\n handler = (x) => x;\n caller() { return this.handler(1); }\n}\n', + ); + + expect(targets).toEqual(['Method:C.js:C.handler']); + }); + it('JavaScript: a `var` closure binding is a Function, like const/let', async () => { // `var` is a different grammar node than const/let, so it kept a Variable // label — and the CALLS edge that resolved through the declaration route