fix(ingestion): class-field closures are callable members in TS/JS (#2693)

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.
This commit is contained in:
Gergo Magyar 2026-07-26 05:56:27 +00:00
parent 6ba8c52a20
commit 5645d6592c
2 changed files with 63 additions and 0 deletions

View file

@ -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

View file

@ -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