[GH-ISSUE #4437] [Feature] Resolve class-instance method calls in JavaScript semantic call graphs #1760

Open
opened 2026-10-16 21:09:57 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @ErenSevenDE on GitHub (Oct 16, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/4437

Before submitting

  • I searched the open and closed issues and pull requests for a related proposal.
  • I understand the contribution scope must be reviewed before integration; open questions are welcome here.
  • This request does not include credentials, private samples, or sensitive target data.

Area

Evidence, artifacts, or reconstruction

Current limitation

trace-javascript-semantics with direction: "callers" cannot find callers of class methods. analyze-javascript-application keeps a call-site for each member call, but resolveLocalCallables returns no target for any MemberExpression callee. That includes the simplest case, where the receiver is a const bound to new C() in the same module. Each such call is recorded as a dynamic-call unknown with the reason "Unsupported MemberExpression call target.".

This is consistent with the documented scope ("uniquely resolved local calls", docs/javascript-application-workflows.md:179), so it is a scope gap, not a correctness bug. REA fails closed here: coverage stays partial and the unknowns are recorded. But for class-based or dependency-injected code, semantic tracing returns only the seed node.

Observed: minimal reproduction (run)

app/sample.mjs:

function localHelper(value) { return value; }
class Loader {
  load(request) { return this.sink(request); }
  sink(request) { return request; }
}
class Service {
  constructor() { this.loader = new Loader(); }
  getLoader() { return this.loader; }
  viaField() { return this.loader.load({ startRow: 0 }); }
  viaGetter() { return this.getLoader().load({ startRow: 1 }); }
}
const direct = new Loader();
direct.load({ startRow: 2 });
localHelper(3);
export { Service };
rea analyze-javascript-application /abs/path/app --json > evidence.json
# then, per function node from semantic_graph.nodes (kind "function"):
#   {"application": <evidence>, "query": {"seed": {"kind": "semantic-node", "node_id": "<fn>"},
#    "direction": "callers", "include_ambiguous_dynamic_edges": <false|true>}}
rea trace-javascript-semantics trace.input.json --json

Call-sites in semantic_graph and their calls relations:

Call (line) Callee form calls relation
localHelper(3) (14) Identifier resolved → localHelper
new Loader() (7) Identifier resolved → Loader
this.sink(request) (3) this.m() none, dynamic-call
this.loader.load(...) (9) this.f.m() none, dynamic-call
this.getLoader().load(...) (10) this.g().m() none, dynamic-call
direct.load(...) (13) const x = new C(); x.m() none, dynamic-call

callers traces (results are the same with include_ambiguous_dynamic_edges false and true):

Seed status / coverage traversed nodes relevant unknowns caller lines
localHelper found / partial 2 0 14
Loader.load found / partial 1 1 none (expected 9, 10, 13)
Loader.sink found / partial 1 0 none (expected 3)

Other unknowns recorded for this file include "Unsupported ClassDeclaration value", "Unsupported NewExpression value", and ambiguous-target property reads/writes (/loader, /load, /sink, /getLoader) described as "receiver is unresolved or not a retained container".

Use cases

Observed at scale (run; third-party target, no source text included): a 23-file, ~4.5k-line slice of a shipped class-based ESM bundle (a commercial data-grid library's server-side row model, carved out by its // packages/... source markers). It had 1228 call-sites and 22 calls relations, with unknowns ambiguous-target 2345, dynamic-call 1206, and unknown-value 1378. Callers of the function that issues the datasource request returned only the seed (8 relevant unknowns). The flow had to be reconstructed by reading the source by hand.

Most framework and library code that uses classes, services, or bean/DI containers would see the same result. I have not measured this beyond the target above.

Desired outcome and success criteria

Callers traces return exact or candidate edges for statically determinable class method dispatch, with explicit uncertainty everywhere else. In rough order of value:

  1. this.m() inside a class method, where m is a non-computed method of the same class body and is not reassigned on this or the prototype in the module.
  2. x.m(), where x has a single stable initializer new C() and C is a local class.
  3. this.f.m(), where this.f = new C() is the only write to f in the class.
  4. Inheritance (extends a local class, super.m()), as candidates.

In the repro: callers(Loader.sink) finds line 3, and callers(Loader.load) finds lines 9, 10 and 13 (exact or ambiguous candidates). Subclass overrides, prototype or instance reassignment, computed keys (see #3310 and #2573), Object.assign/mixins, and receivers from outside the module keep producing ambiguous or dynamic-call unknowns, never false exact edges.

Expected interaction with targets

Read existing files or captured data

Target and scope details (optional)

Static JavaScript analysis only; no execution. Non-goal: resolving dispatch through receivers whose class is defined outside the analyzed source.

Verification available so far

I can suggest a sample or manual check

Verification details (optional)

The repro above is self-written and can serve as a fixture. Expected checks: the call-site table shows calls relations for lines 3, 9, 10 and 13, and the two callers traces return those lines, with no exact edge for any variant that reassigns the method or the receiver.

Open question (observed; cause unknown): callers(Loader.sink) reports relevant_unknowns: 0, even though the call this.sink(request) exists and is a recorded dynamic-call. callers(Loader.load) reports 1. Overall coverage is partial in both cases, so nothing claims completeness. Still, a caller would reasonably read the 0 as "no unresolved call could reach this function". I have not checked how relevant_unknowns is selected. This may deserve its own report.

Alternatives considered

  • Seeding by property (getRows, load) matches only property-slot nodes and returned 0 matches for method calls.
  • include_ambiguous_dynamic_edges: true did not change the results, because no candidate edges are produced for these call-sites.
  • Manual source reading of the files REA identified works, but it loses Evidence provenance.

Possible direction

Source-derived only, not executed, at d72d856f5:

  • src/domain/javascript/javascriptSemanticCalls.ts:129 passes node.callee to resolveLocalCallables for every call or new expression. A site that does not resolve exactly records a dynamic-call frontier with resolution.reason.
  • src/domain/javascript/javascriptSemanticCallResolution.ts:59-86 (resolveCallables) handles only a direct callable node, Identifier, and ConditionalExpression/LogicalExpression. Every other callee type, including MemberExpression and OptionalMemberExpression, returns `Unsupported ${node.type} call target.`. I found no other caller of resolveLocalCallables outside tests.

A MemberExpression branch could resolve a static key on a receiver whose class identity is proven (this inside a class body, or a binding with a stable new LocalClass() initializer) to that class's method callable. It would downgrade to ambiguous when overrides, reassignments, or subclasses exist. This is a proposal only.

References

  • Related but distinct: #3310 (class method name creates a false lexical read), #3624 (native C++ virtual dispatch), #3839 (getter-result expansion blowup), #4349 (alias cycles in call resolution).
  • Environment: REA 6.5.0, source d72d856f58d4747c5eb4307426e80d20934e017c run from scripts/rea.mjs; Node.js 24.14.1; Windows 11 Enterprise x64; CLI.
Originally created by @ErenSevenDE on GitHub (Oct 16, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4437 ### Before submitting - [x] I searched the open and closed issues and pull requests for a related proposal. - [x] I understand the [contribution scope](https://github.com/morluto/rea/blob/main/CONTRIBUTING.md#contribution-scope) must be reviewed before integration; open questions are welcome here. - [x] This request does not include credentials, private samples, or sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Current limitation `trace-javascript-semantics` with `direction: "callers"` cannot find callers of class methods. `analyze-javascript-application` keeps a call-site for each member call, but `resolveLocalCallables` returns no target for any `MemberExpression` callee. That includes the simplest case, where the receiver is a `const` bound to `new C()` in the same module. Each such call is recorded as a `dynamic-call` unknown with the reason `"Unsupported MemberExpression call target."`. This is consistent with the documented scope ("uniquely resolved local calls", `docs/javascript-application-workflows.md:179`), so it is a scope gap, not a correctness bug. REA fails closed here: coverage stays `partial` and the unknowns are recorded. But for class-based or dependency-injected code, semantic tracing returns only the seed node. **Observed: minimal reproduction (run)** `app/sample.mjs`: ```js function localHelper(value) { return value; } class Loader { load(request) { return this.sink(request); } sink(request) { return request; } } class Service { constructor() { this.loader = new Loader(); } getLoader() { return this.loader; } viaField() { return this.loader.load({ startRow: 0 }); } viaGetter() { return this.getLoader().load({ startRow: 1 }); } } const direct = new Loader(); direct.load({ startRow: 2 }); localHelper(3); export { Service }; ``` ```bash rea analyze-javascript-application /abs/path/app --json > evidence.json # then, per function node from semantic_graph.nodes (kind "function"): # {"application": <evidence>, "query": {"seed": {"kind": "semantic-node", "node_id": "<fn>"}, # "direction": "callers", "include_ambiguous_dynamic_edges": <false|true>}} rea trace-javascript-semantics trace.input.json --json ``` Call-sites in `semantic_graph` and their `calls` relations: | Call (line) | Callee form | `calls` relation | |---|---|---| | `localHelper(3)` (14) | Identifier | resolved → `localHelper` | | `new Loader()` (7) | Identifier | resolved → `Loader` | | `this.sink(request)` (3) | `this.m()` | none, `dynamic-call` | | `this.loader.load(...)` (9) | `this.f.m()` | none, `dynamic-call` | | `this.getLoader().load(...)` (10) | `this.g().m()` | none, `dynamic-call` | | `direct.load(...)` (13) | `const x = new C(); x.m()` | none, `dynamic-call` | `callers` traces (results are the same with `include_ambiguous_dynamic_edges` false and true): | Seed | status / coverage | traversed nodes | relevant unknowns | caller lines | |---|---|---|---|---| | `localHelper` | found / partial | 2 | 0 | 14 | | `Loader.load` | found / partial | 1 | 1 | none (expected 9, 10, 13) | | `Loader.sink` | found / partial | 1 | **0** | none (expected 3) | Other unknowns recorded for this file include `"Unsupported ClassDeclaration value"`, `"Unsupported NewExpression value"`, and `ambiguous-target` property reads/writes (`/loader`, `/load`, `/sink`, `/getLoader`) described as `"receiver is unresolved or not a retained container"`. ### Use cases Observed at scale (run; third-party target, no source text included): a 23-file, ~4.5k-line slice of a shipped class-based ESM bundle (a commercial data-grid library's server-side row model, carved out by its `// packages/...` source markers). It had 1228 call-sites and 22 `calls` relations, with unknowns `ambiguous-target` 2345, `dynamic-call` 1206, and `unknown-value` 1378. Callers of the function that issues the datasource request returned only the seed (8 relevant unknowns). The flow had to be reconstructed by reading the source by hand. Most framework and library code that uses classes, services, or bean/DI containers would see the same result. I have not measured this beyond the target above. ### Desired outcome and success criteria Callers traces return exact or candidate edges for statically determinable class method dispatch, with explicit uncertainty everywhere else. In rough order of value: 1. `this.m()` inside a class method, where `m` is a non-computed method of the same class body and is not reassigned on `this` or the prototype in the module. 2. `x.m()`, where `x` has a single stable initializer `new C()` and `C` is a local class. 3. `this.f.m()`, where `this.f = new C()` is the only write to `f` in the class. 4. Inheritance (`extends` a local class, `super.m()`), as candidates. In the repro: `callers(Loader.sink)` finds line 3, and `callers(Loader.load)` finds lines 9, 10 and 13 (exact or ambiguous candidates). Subclass overrides, prototype or instance reassignment, computed keys (see #3310 and #2573), `Object.assign`/mixins, and receivers from outside the module keep producing `ambiguous` or `dynamic-call` unknowns, never false `exact` edges. ### Expected interaction with targets Read existing files or captured data ### Target and scope details (optional) Static JavaScript analysis only; no execution. Non-goal: resolving dispatch through receivers whose class is defined outside the analyzed source. ### Verification available so far I can suggest a sample or manual check ### Verification details (optional) The repro above is self-written and can serve as a fixture. Expected checks: the call-site table shows `calls` relations for lines 3, 9, 10 and 13, and the two `callers` traces return those lines, with no `exact` edge for any variant that reassigns the method or the receiver. Open question (observed; cause unknown): `callers(Loader.sink)` reports `relevant_unknowns: 0`, even though the call `this.sink(request)` exists and is a recorded `dynamic-call`. `callers(Loader.load)` reports 1. Overall coverage is `partial` in both cases, so nothing claims completeness. Still, a caller would reasonably read the 0 as "no unresolved call could reach this function". I have not checked how `relevant_unknowns` is selected. This may deserve its own report. ### Alternatives considered - Seeding by `property` (`getRows`, `load`) matches only property-slot nodes and returned 0 matches for method calls. - `include_ambiguous_dynamic_edges: true` did not change the results, because no candidate edges are produced for these call-sites. - Manual source reading of the files REA identified works, but it loses Evidence provenance. ### Possible direction Source-derived only, not executed, at `d72d856f5`: - `src/domain/javascript/javascriptSemanticCalls.ts:129` passes `node.callee` to `resolveLocalCallables` for every call or `new` expression. A site that does not resolve exactly records a `dynamic-call` frontier with `resolution.reason`. - `src/domain/javascript/javascriptSemanticCallResolution.ts:59-86` (`resolveCallables`) handles only a direct callable node, `Identifier`, and `ConditionalExpression`/`LogicalExpression`. Every other callee type, including `MemberExpression` and `OptionalMemberExpression`, returns `` `Unsupported ${node.type} call target.` ``. I found no other caller of `resolveLocalCallables` outside tests. A `MemberExpression` branch could resolve a static key on a receiver whose class identity is proven (`this` inside a class body, or a binding with a stable `new LocalClass()` initializer) to that class's method callable. It would downgrade to `ambiguous` when overrides, reassignments, or subclasses exist. This is a proposal only. ### References - Related but distinct: #3310 (class method *name* creates a false lexical read), #3624 (native C++ virtual dispatch), #3839 (getter-result expansion blowup), #4349 (alias cycles in call resolution). - Environment: REA `6.5.0`, source `d72d856f58d4747c5eb4307426e80d20934e017c` run from `scripts/rea.mjs`; Node.js 24.14.1; Windows 11 Enterprise x64; CLI.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1760
No description provided.