diff --git a/crates/codegraph-core/src/extractors/javascript.rs b/crates/codegraph-core/src/extractors/javascript.rs index 092407f4b..1ee3603c2 100644 --- a/crates/codegraph-core/src/extractors/javascript.rs +++ b/crates/codegraph-core/src/extractors/javascript.rs @@ -1751,17 +1751,29 @@ fn handle_call_expr( handle_dynamic_import(node, &fn_node, source, symbols); return; } - // `this(args)` and `super(args)` — the callee is `this`/`super` used as a - // function, not a named identifier. The `this` call record is emitted by + // `this(args)` — the callee is `this` used as a function, not a named + // identifier. The `this` call record is emitted by // collect_this_call_and_bindings (called from match_js_pts_bindings). - // Neither case should emit callback-reference calls for the arguments, because - // those arguments are values passed *to* the rebound function — not callbacks - // of the enclosing scope. Without this guard, identifier arguments like `b` - // in `this(b)` or `a` in `super(a)` become spurious dynamic calls that the - // pts resolver resolves to globally-defined functions with the same name in - // other files, producing false cross-file call edges. - // Mirrors the early-return guard in the TS handleCallExpr (javascript.ts:1135). - if fn_node.kind() == "this" || fn_node.kind() == "super" { + // Callback-reference-call extraction is skipped for the arguments, because + // those arguments are values passed *to* the rebound function — not + // callbacks of the enclosing scope. Without this guard, an identifier + // argument like `b` in `this(b)` becomes a spurious dynamic call that the + // pts resolver resolves to a globally-defined function with the same name + // in another file, producing a false cross-file call edge. + if fn_node.kind() == "this" { + return; + } + // Bare `super(args)` — invokes the parent class's constructor. Modeled as + // a `constructor` call with receiver `super` (mirrors the `super` branch + // in extractCallInfo, src/extractors/javascript.ts) so it flows through + // the same this/super hierarchy dispatch that already resolves + // `super.method()` to the parent class (#1929). Callback-reference-call + // extraction on the arguments is skipped for the same reason as + // `this(args)` above. + if fn_node.kind() == "super" { + if let Some(call_info) = extract_call_info(&fn_node, node, source) { + symbols.calls.push(call_info); + } return; } // RES-2: {a:fnA,b:fnB}[k]() — inline object literal dispatch table. @@ -3412,6 +3424,14 @@ fn extract_call_info(fn_node: &Node, call_node: &Node, source: &[u8]) -> Option< } None } + // Bare `super(...)` — see the early dispatch in `handle_call_expr` for why + // callback-reference-call extraction is skipped for the arguments here. + "super" => Some(Call { + name: "constructor".to_string(), + line: start_line(call_node), + receiver: Some("super".to_string()), + ..Default::default() + }), _ => None, } } @@ -6799,6 +6819,29 @@ mod tests { ); } + /// Bare `super(...)` must be extracted as a `constructor` call with + /// receiver `super`, mirroring `super.method()` — the this/super hierarchy + /// dispatch (WASM-mirrored `resolveThisDispatch`) then attributes it to the + /// parent class's constructor (#1929). + #[test] + fn bare_super_call_extracted_as_constructor_call() { + let s = parse_js( + "class E { constructor(c) { this.cc = c; } }\n\ + class G extends E {\n\ + constructor(a) { super(a); }\n\ + }", + ); + let super_call = s + .calls + .iter() + .find(|c| c.name == "constructor" && c.receiver.as_deref() == Some("super")); + assert!( + super_call.is_some(), + "bare super(...) must be recorded as a constructor call with receiver=super; got: {:?}", + s.calls.iter().map(|c| (&c.name, &c.receiver)).collect::>() + ); + } + #[test] fn array_elem_bindings_recorded() { let s = parse_js( diff --git a/src/domain/parser.ts b/src/domain/parser.ts index 79f3c2738..c90ccab27 100644 --- a/src/domain/parser.ts +++ b/src/domain/parser.ts @@ -175,6 +175,7 @@ const COMMON_QUERY_PATTERNS: string[] = [ '(call_expression function: (identifier) @callfn_name) @callfn_node', '(call_expression function: (member_expression) @callmem_fn) @callmem_node', '(call_expression function: (subscript_expression) @callsub_fn) @callsub_node', + '(call_expression function: (super) @callsuper_fn) @callsuper_node', '(new_expression constructor: (identifier) @newfn_name) @newfn_node', '(new_expression constructor: (member_expression) @newmem_fn) @newmem_node', '(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node', diff --git a/src/domain/wasm-worker-entry.ts b/src/domain/wasm-worker-entry.ts index 8584b44ff..9b659ceb1 100644 --- a/src/domain/wasm-worker-entry.ts +++ b/src/domain/wasm-worker-entry.ts @@ -123,6 +123,7 @@ const COMMON_QUERY_PATTERNS: string[] = [ '(call_expression function: (identifier) @callfn_name) @callfn_node', '(call_expression function: (member_expression) @callmem_fn) @callmem_node', '(call_expression function: (subscript_expression) @callsub_fn) @callsub_node', + '(call_expression function: (super) @callsuper_fn) @callsuper_node', '(new_expression constructor: (identifier) @newfn_name) @newfn_node', '(new_expression constructor: (member_expression) @newmem_fn) @newmem_node', '(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node', diff --git a/src/extractors/javascript.ts b/src/extractors/javascript.ts index 0e4cf54bb..583a26b39 100644 --- a/src/extractors/javascript.ts +++ b/src/extractors/javascript.ts @@ -387,6 +387,11 @@ function dispatchQueryMatch( } else if (c.newmem_node) { const callInfo = extractCallInfo(c.newmem_fn!, c.newmem_node); if (callInfo) calls.push(callInfo); + } else if (c.callsuper_node) { + // Bare `super(...)` constructor call — see extractCallInfo's 'super' branch. + const callInfo = extractCallInfo(c.callsuper_fn!, c.callsuper_node); + if (callInfo) calls.push(callInfo); + calls.push(...extractCallbackReferenceCalls(c.callsuper_node, callbackParamShapes)); } else if (c.assign_node) { handleCommonJSAssignment(c.assign_left!, c.assign_right!, c.assign_node, imports); handleFuncPropAssignment(c.assign_left!, c.assign_right!, definitions); @@ -3577,6 +3582,14 @@ function extractCallInfo( if (fnType === 'subscript_expression') { return extractSubscriptCallInfo(fn, callNode, arrayElemBindings); } + if (fnType === 'super') { + // Bare `super(...)` — invokes the parent class's constructor. Modeled as a + // `constructor` call with receiver `super` so it flows through the same + // this/super hierarchy dispatch as `super.method()` (resolveThisDispatch + // in cha.ts walks to the caller's parent class and looks up + // `ParentClass.constructor`), rather than needing a bespoke resolution path. + return { name: 'constructor', line: nodeStartLine(callNode), receiver: 'super' }; + } return null; } diff --git a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json index bee806617..b43c9513a 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json @@ -248,6 +248,20 @@ "mode": "class-inheritance", "notes": "static super.count() in DoubleCounter.count resolves to Counter.count via CHA parents map" }, + { + "source": { "name": "Car.constructor", "file": "inheritance.js" }, + "target": { "name": "Vehicle.constructor", "file": "inheritance.js" }, + "kind": "calls", + "mode": "class-inheritance", + "notes": "bare super(make) in Car.constructor resolves to Vehicle.constructor via CHA parents map (#1929)" + }, + { + "source": { "name": "SportsCar.constructor", "file": "inheritance.js" }, + "target": { "name": "Car.constructor", "file": "inheritance.js" }, + "kind": "calls", + "mode": "class-inheritance", + "notes": "bare super(make, model) in SportsCar.constructor resolves to Car.constructor (nearest parent) (#1929)" + }, { "source": { "name": "runPrototypes", "file": "prototypes.js" }, "target": { "name": "C", "file": "prototypes.js" }, diff --git a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js index 7ec19aa8f..f9e5e1875 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/inheritance.js +++ b/tests/benchmarks/resolution/fixtures/javascript/inheritance.js @@ -32,3 +32,25 @@ export class DoubleCounter extends Counter { return super.count() * 2; // static super.count() → Counter.count via CHA parents map } } + +// Bare super(...) constructor call — same CHA parents-map resolution as +// super.method(), but for the keyword-callee constructor invocation (#1929) +export class Vehicle { + constructor(make) { + this.make = make; + } +} + +export class Car extends Vehicle { + constructor(make, model) { + super(make); // bare super(...) → Vehicle.constructor + this.model = model; + } +} + +export class SportsCar extends Car { + constructor(make, model, topSpeed) { + super(make, model); // bare super(...) → Car.constructor (nearest parent) + this.topSpeed = topSpeed; + } +} diff --git a/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json b/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json index 69366d35f..4e1ada5b8 100644 --- a/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/typescript/expected-edges.json @@ -199,6 +199,13 @@ "mode": "receiver-typed", "notes": "shape.describe() — shape typed as Shape" }, + { + "source": { "name": "Ellipse.constructor", "file": "hierarchy.ts" }, + "target": { "name": "Circle.constructor", "file": "hierarchy.ts" }, + "kind": "calls", + "mode": "class-inheritance", + "notes": "bare super(rx) in Ellipse.constructor resolves to Circle.constructor via CHA parents map (#1929). Circle/Rectangle's own super() calls resolve to Shape's implicit default constructor, which has no declared node to attribute to, so they correctly produce no edge." + }, { "source": { "name": "makeCircle", "file": "hierarchy.ts" }, "target": { "name": "Circle", "file": "hierarchy.ts" }, @@ -331,6 +338,13 @@ "kind": "calls", "mode": "class-inheritance", "notes": "super.log() in PrefixLogger.log resolves to TimestampLogger.log (nearest parent)" + }, + { + "source": { "name": "Box.constructor", "file": "super-dispatch.ts" }, + "target": { "name": "Container.constructor", "file": "super-dispatch.ts" }, + "kind": "calls", + "mode": "class-inheritance", + "notes": "bare super(label) in Box.constructor resolves to Container.constructor via CHA parents map (#1929)" } ] } diff --git a/tests/benchmarks/resolution/fixtures/typescript/super-dispatch.ts b/tests/benchmarks/resolution/fixtures/typescript/super-dispatch.ts index 554b6ebcf..148c9a8fd 100644 --- a/tests/benchmarks/resolution/fixtures/typescript/super-dispatch.ts +++ b/tests/benchmarks/resolution/fixtures/typescript/super-dispatch.ts @@ -19,3 +19,20 @@ export class PrefixLogger extends TimestampLogger { super.log(`[PREFIX] ${msg}`); // super.method() → TimestampLogger.log (nearest parent) } } + +// Bare super(...) constructor call — same CHA parents-map resolution as +// super.method(), but for the keyword-callee constructor invocation (#1929). +// Named distinctly from hierarchy.ts's Shape/Circle/Rectangle/Ellipse to avoid +// cross-file base-class name collisions in the global CHA parents map. +export class Container { + constructor(public label: string) {} +} + +export class Box extends Container { + constructor( + label: string, + public sides: number, + ) { + super(label); // bare super(...) → Container.constructor + } +} diff --git a/tests/engines/query-walk-parity.test.ts b/tests/engines/query-walk-parity.test.ts index 4c891e142..3d3ac8059 100644 --- a/tests/engines/query-walk-parity.test.ts +++ b/tests/engines/query-walk-parity.test.ts @@ -291,6 +291,26 @@ function fnB() {} function run(key) { return ({ a: fnA, b: fnB })[key](); } +`, + }, + { + // Regression guard for #1929: bare `super(...)` constructor calls must be + // extracted as a `constructor` call with receiver `super` on both paths — + // the query path via the new `callsuper_node` capture in + // `dispatchQueryMatch`, the walk path via `handleCallExpr`'s fall-through + // to `extractCallInfo`'s `super` branch. + name: 'bare super(...) constructor call (#1929)', + file: 'test.js', + code: ` +class Base { + constructor(a) { this.a = a; } +} +class Derived extends Base { + constructor(a, b) { + super(a); + this.b = b; + } +} `, }, // TSX diff --git a/tests/fixtures/cha-dispatch/Car.ts b/tests/fixtures/cha-dispatch/Car.ts new file mode 100644 index 000000000..e9628a728 --- /dev/null +++ b/tests/fixtures/cha-dispatch/Car.ts @@ -0,0 +1,11 @@ +import { Vehicle } from './Vehicle.js'; + +export class Car extends Vehicle { + model: string; + + constructor(make: string, model: string) { + // super-dispatch: bare super(...) resolves to Vehicle.constructor via parents map (#1929). + super(make); + this.model = model; + } +} diff --git a/tests/fixtures/cha-dispatch/Vehicle.ts b/tests/fixtures/cha-dispatch/Vehicle.ts new file mode 100644 index 000000000..c8315fc63 --- /dev/null +++ b/tests/fixtures/cha-dispatch/Vehicle.ts @@ -0,0 +1,7 @@ +export class Vehicle { + make: string; + + constructor(make: string) { + this.make = make; + } +} diff --git a/tests/integration/phase-8.5-cha-dispatch.test.ts b/tests/integration/phase-8.5-cha-dispatch.test.ts index 18c33f817..e67a08cf4 100644 --- a/tests/integration/phase-8.5-cha-dispatch.test.ts +++ b/tests/integration/phase-8.5-cha-dispatch.test.ts @@ -161,6 +161,21 @@ describe.each(ENGINES)('Phase 8.5 CHA dispatch (%s)', (engine) => { ).toBeUndefined(); }); + // ── bare super(...) constructor call (issue #1929) ────────────────────── + + it('super-dispatch: emits Car.constructor → Vehicle.constructor (bare super(...) call)', () => { + const edge = callEdges.find( + (e) => + e.caller_name === 'Car.constructor' && + e.callee_name === 'Vehicle.constructor' && + e.callee_file === 'Vehicle.ts', + ); + expect( + edge, + `Expected Car.constructor → Vehicle.constructor edge (bare super(...) dispatch via class hierarchy).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`, + ).toBeDefined(); + }); + // ── transitive multi-level CHA (issue #1311) ─────────────────────────── // Hierarchy: IJob → AbstractJob (non-instantiated) → PrintJob / ScanJob // resolveChaTargets must BFS through AbstractJob to reach the concrete types. diff --git a/tests/parsers/javascript.test.ts b/tests/parsers/javascript.test.ts index cf4e93794..8d1cda2e8 100644 --- a/tests/parsers/javascript.test.ts +++ b/tests/parsers/javascript.test.ts @@ -2373,6 +2373,39 @@ function runDemo(users: string[]): void { }); }); + describe('bare super(...) constructor call extraction (#1929)', () => { + it('records bare super(...) as a constructor call with receiver=super', () => { + const symbols = parseJS( + `class Base { constructor(a) { this.a = a; } } + class Derived extends Base { constructor(a, b) { super(a); this.b = b; } }`, + ); + const superCall = symbols.calls.find( + (c) => c.name === 'constructor' && c.receiver === 'super', + ); + expect(superCall).toBeDefined(); + expect(superCall!.dynamic).toBeFalsy(); + }); + + it('records bare super(...) from a class expression constructor', () => { + const symbols = parseJS( + `function mixin() { return class PostMixin extends A { constructor() { super(); } }; }`, + ); + const superCall = symbols.calls.find( + (c) => c.name === 'constructor' && c.receiver === 'super', + ); + expect(superCall).toBeDefined(); + }); + + it('does not emit super(...) arguments as spurious callback-reference calls', () => { + const symbols = parseJS( + `class Base { constructor(a, b) { this.a = a; this.b = b; } } + class Derived extends Base { constructor(a, b) { super(a, b); } }`, + ); + expect(symbols.calls.some((c) => c.name === 'a')).toBe(false); + expect(symbols.calls.some((c) => c.name === 'b')).toBe(false); + }); + }); + describe('array destructuring constant extraction (#1471, #1901)', () => { it('extracts one constant definition per bound identifier in a const array pattern', () => { // Per-element extraction (#1901) supersedes the prior single-node