Skip to content

fix(resolver): attribute new ClassName() calls to the constructor method#2028

Open
carlos-alm wants to merge 1 commit into
fix/issue-1888-same-file-bare-name-lookup-resolvecalltargetsfrom
fix/issue-1892-constructor-calls-new-x-resolve-to-the-class
Open

fix(resolver): attribute new ClassName() calls to the constructor method#2028
carlos-alm wants to merge 1 commit into
fix/issue-1888-same-file-bare-name-lookup-resolvecalltargetsfrom
fix/issue-1892-constructor-calls-new-x-resolve-to-the-class

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

new ClassName() (and bare ClassName() in languages with no new keyword) always resolved only to the class declaration node, never to the class's own constructor method, even when the class declared one explicitly. Every constructor in the codebase therefore reported zero dependents via fn-impact/roles --role dead, no matter how many call sites actually constructed the class.

Root cause

  • handleNewExpr (JS/TS) and its per-language equivalents emit a bare (no-receiver) call named after the class.
  • That bare name always resolves through resolveCallTargets/resolveByGlobal to the class declaration node (stored under the bare name), never to the constructor method (stored under a separate qualified name — ClassName.constructor for JS/TS, ClassName.__init__ for Python, ClassName.ClassName for Java/C#/Dart/Groovy, whose constructor identifier equals the class name).

Fix

resolveCallTargets (src/domain/graph/builder/call-resolver.ts) and resolve_call_targets (crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs) now additionally resolve the class's own constructor method for any bare call that lands on a class-kind target, and emit a second calls edge to it alongside the existing class-node edge (attachConstructorTargets / attach_constructor_targets in strategy.ts / build_edges.rs).

This is additive, not a replacement: the class-node edge is always kept, because buildChaContextFromDb's RTA fallback (incremental rebuilds, builder/cha.ts) reads instantiation evidence from calls edges targeting class-kind nodes. A class with no explicit constructor still resolves only to the class node — there's nothing else to attribute the call to, and the fix doesn't fabricate an edge.

The constructor's local name is resolved per-language via a small extension-keyed table (mirrored identically in both engines):

Extensions Constructor identifier
.js .mjs .cjs .jsx .ts .tsx .mts .cts constructor
.py .pyi __init__
.php .phtml __construct
.java .cs .dart .groovy .gvy same as the class name itself

Kotlin, Swift, and Scala are deliberately excluded — their extractors don't emit an explicit constructor definition at all, so there's nothing to attribute to. C++ is excluded because its extractor doesn't track object-construction call sites at all.

Test plan

  • New integration test (tests/integration/issue-1892-constructor-call-attribution.test.ts) covering JS/TS, Python, and Java, on both native and WASM engines: verifies the constructor-method edge is added when a class declares one, and verifies no edge is fabricated when it doesn't.
  • Updated the javascript/python/csharp/dynamic-typescript resolution benchmark fixtures — their expected-edges.json ground truth predated this fix and only encoded the class-node edge; the new constructor-method edges are legitimate true positives, not regressions.
  • npm test — full suite green except a single pre-existing flake tracked separately (Flaky cycle-node ordering in checkNoNewCycles full-suite run (tests/integration/check.test.ts) #1990), unrelated to this change.
  • npm run lint — clean on touched files.
  • cargo test — 577 passed. cargo check --workspace / cargo clippy --workspace clean on touched files (pre-existing warnings elsewhere untouched).
  • Native addon rebuilt, codesigned, verified directly against both the fix and a reverted baseline to confirm every resolution-benchmark change is a genuine improvement, not a regression.
  • codegraph diff-impact --staged -T — contained impact (4 functions changed, 18 transitive callers, all within the resolver).

Out of scope

Found and filed separately while testing, not touched here:

Fixes #1892

`new ClassName()` (and bare `ClassName()` in languages with no `new`
keyword) always resolved only to the class declaration node, never to
the class's own constructor method, even when one was explicitly
declared. Every constructor in the codebase therefore reported zero
dependents via `fn-impact`/`roles --role dead`, regardless of how many
call sites actually constructed the class.

resolveCallTargets (call-resolver.ts) and resolve_call_targets
(build_edges.rs) now additionally resolve the class's own constructor
method for any bare (no-receiver) call that lands on a class-kind
target, and emit a second calls edge to it alongside the existing
class-node edge (attachConstructorTargets / attach_constructor_targets
in strategy.ts / build_edges.rs). The class-node edge is kept:
buildChaContextFromDb's RTA fallback for incremental rebuilds reads
instantiation evidence from calls edges targeting class-kind nodes.

The constructor's local name is resolved per-language (JS/TS:
"constructor", Python: "__init__", PHP: "__construct", Java/C#/Dart/
Groovy: same identifier as the class itself), covering every language
whose extractor both tracks object-construction call sites and emits
an explicit constructor definition.

Updates the javascript/python/csharp/dynamic-typescript resolution
benchmark fixtures, whose expected-edges.json ground truth predated
this fix and only encoded the class-node edge.

Impact: 4 functions changed, 18 affected
@greptile-apps

greptile-apps Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a long-standing attribution gap where new ClassName() (and bare ClassName() in Python/Groovy) resolved only to the class declaration node, never to the class's own constructor method. fn-impact and roles --role dead therefore always reported zero dependents for every constructor regardless of actual call-site count.

  • Adds a post-processing step attachConstructorTargets / attach_constructor_targets (mirrored in TS and Rust/WASM) that, for any bare call whose targets include a class-kind node, additionally resolves and emits a calls edge to that class's constructor method using a per-language extension map.
  • The fix is additive (the class-node edge is preserved for the incremental RTA fallback) and no-ops when a class has no explicit constructor.
  • Benchmark fixtures for JS, Python, C#, and dynamic-TS are updated with the newly correct constructor-method edges; a new integration test covers JS/TS, Python, and Java on both WASM and native engines.

Confidence Score: 4/5

The change is well-contained, additive-only, and mirrors identically between the TS and Rust engines. The existing class-node edge is always preserved, so incremental-rebuild RTA evidence is unaffected.

The core logic is correct and the Rust/TS symmetry is carefully maintained. The only gaps are missing integration test coverage for PHP, Dart, and Groovy constructors (all use the same extension-map mechanism as the tested languages), and a minor type-precision issue in call-resolver.ts that does not affect runtime behaviour. The benchmark fixture updates are all legitimate true positives verified against both engines.

The integration test file could benefit from a PHP/Dart fixture to close the coverage gap; src/domain/graph/builder/call-resolver.ts has a minor type-precision nit around the kind property on targets.

Important Files Changed

Filename Overview
src/domain/graph/resolver/strategy.ts New core of the fix: exports attachConstructorTargets and adds per-language CONSTRUCTOR_LOCAL_NAME_BY_EXTENSION map + constructorLocalName / resolveConstructorTarget helpers. Logic is sound and well-guarded; the CTOR_NAME_SAME_AS_CLASS sentinel neatly separates keyword-named constructors (JS/TS/Python/PHP) from identity-named ones (Java/C#/Dart/Groovy).
src/domain/graph/builder/call-resolver.ts Integrates attachConstructorTargets after the existing resolution tiers, gated on !call.receiver. Passes targetName (import-alias-resolved) so renamed imports correctly look up Foo.constructor not Bar.constructor. The targets local variable is typed without kind, but the generic constraint in attachConstructorTargets ensures runtime access to kind is still correct.
crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs Rust/WASM mirror of the TS fix: splits the existing resolve_call_targets into a thin public wrapper + resolve_call_targets_core, adds constructor_local_name / resolve_constructor_target / attach_constructor_targets. Lifetime handling and HashSet de-duplication pattern are clean.
tests/integration/issue-1892-constructor-call-attribution.test.ts Covers JS/TS, Python, and Java positive and negative cases on both WASM and native engines. PHP, Dart, and Groovy constructors are not covered by these integration tests.
tests/benchmarks/resolution/fixtures/csharp/expected-edges.json Adds two new edges attributing new UserService(repo) to UserService.UserService (the C# constructor method); existing class-node edges are preserved, consistent with the additive design.
tests/benchmarks/resolution/fixtures/javascript/expected-edges.json Adds three new constructor-method edges for UserService.constructor and Logger.constructor; all are true positives that the pre-fix resolver was silently missing.
tests/benchmarks/resolution/fixtures/python/expected-edges.json Adds four new __init__ constructor-method edges for Order, User, UserService, and UserRepository; all correctly pair with the pre-existing class-node edges.
tests/benchmarks/resolution/fixtures/dynamic-typescript/expected-edges.json Adds a single constructor-method edge for Reflect.construct(UserService, args)UserService.constructor; correctly captures the constructor attribution for reflection-based construction.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["resolveCallTargets / resolve_call_targets"] --> B["resolve_call_targets_core\n(existing multi-tier resolution)"]
    B --> C{call.receiver?}
    C -- yes --> D[Return targets as-is]
    C -- no --> E["attachConstructorTargets(targets, className)"]
    E --> F{For each target}
    F -- kind != class --> G[Skip]
    F -- kind == class --> H["constructorLocalName(target.file, className)"]
    H -- ext not in map --> I[No constructor edge]
    H -- JS/TS --> J["className.constructor"]
    H -- Python --> K["className.__init__"]
    H -- PHP --> L["className.__construct"]
    H -- Java/C#/Dart/Groovy --> M["className.className"]
    J & K & L & M --> N["lookup.byNameAndFile(qualifiedName, target.file)\nfilter kind==method"]
    N -- found, not duplicate --> O["Append constructor node to augmented list"]
    N -- not found --> I
    O --> P["Return original class-node edge + new constructor-method edge"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["resolveCallTargets / resolve_call_targets"] --> B["resolve_call_targets_core\n(existing multi-tier resolution)"]
    B --> C{call.receiver?}
    C -- yes --> D[Return targets as-is]
    C -- no --> E["attachConstructorTargets(targets, className)"]
    E --> F{For each target}
    F -- kind != class --> G[Skip]
    F -- kind == class --> H["constructorLocalName(target.file, className)"]
    H -- ext not in map --> I[No constructor edge]
    H -- JS/TS --> J["className.constructor"]
    H -- Python --> K["className.__init__"]
    H -- PHP --> L["className.__construct"]
    H -- Java/C#/Dart/Groovy --> M["className.className"]
    J & K & L & M --> N["lookup.byNameAndFile(qualifiedName, target.file)\nfilter kind==method"]
    N -- found, not duplicate --> O["Append constructor node to augmented list"]
    N -- not found --> I
    O --> P["Return original class-node edge + new constructor-method edge"]
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(resolver): attribute new ClassName()..." | Re-trigger Greptile

Comment on lines +191 to +209
});
});

it('Java: new Corge() resolves only to Corge (class) — no explicit constructor to attribute to', () => {
expect(edges).toContainEqual({
src: 'Wrapper.run',
srcKind: 'method',
tgt: 'Corge',
tgtKind: 'class',
});
expect(edges.some((e) => e.src === 'Wrapper.run' && e.tgt === 'Corge.Corge')).toBe(false);
});
});
}

runScenario('wasm');
describe.skipIf(!isNativeAvailable())('native engine coverage', () => {
runScenario('native');
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 PHP __construct not covered by any integration test

The extension map in both strategy.ts and build_edges.rs includes .php/.phtml__construct, but neither the new integration test nor any existing benchmark fixture exercises this path. PHP's constructor convention (__construct) is a fixed keyword (like __init__), unlike the Java/C#/Dart/Groovy "same as class name" pattern, so a typo in the keyword or a mismatch between the TS and Rust arms would go undetected. Dart and Groovy are in the same position.

A minimal fixture analogous to the Python Baz/Qux scenario would validate all three extension families: keyword-fixed (PHP), class-name-identical (Java/C#/Dart/Groovy), and Python-style (__init__). Java is already tested; PHP/Dart/Groovy are not.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Comment on lines +110 to +111
const entry = CONSTRUCTOR_LOCAL_NAME_BY_EXTENSION.get(file.slice(dotIdx));
if (!entry) return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 !entry guard conflates undefined with empty string

CONSTRUCTOR_LOCAL_NAME_BY_EXTENSION values are string | typeof CTOR_NAME_SAME_AS_CLASS. Map.get() returns undefined when the key is absent. The guard if (!entry) return null would also short-circuit on an empty string "", which cannot appear in the current map but could silently corrupt a future extension entry. Prefer an explicit undefined check to make the intent clear and guard against accidental falsy values.

Suggested change
const entry = CONSTRUCTOR_LOCAL_NAME_BY_EXTENSION.get(file.slice(dotIdx));
if (!entry) return null;
const entry = CONSTRUCTOR_LOCAL_NAME_BY_EXTENSION.get(file.slice(dotIdx));
if (entry === undefined) return null;

Fix in Claude Code

Comment on lines +326 to +332
let resolved = [...(targets ?? [])];
// #1892: `new ClassName()` / bare `ClassName()` (keyword-less languages)
// always resolves as a bare (no-receiver) call — augment any class-kind
// match with the class's own constructor method, if it declares one.
if (!call.receiver) {
resolved = attachConstructorTargets(lookup, resolved, targetName);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 targets declared without kind, but attachConstructorTargets relies on it at runtime

targets is typed as ReadonlyArray<{ id: number; file: string }> (no kind), so resolved inherits that narrower type. When attachConstructorTargets is called, TypeScript infers T = { id: number; file: string }, satisfying the generic constraint only because kind is optional in the bound. At runtime, the actual objects returned by lookup.byNameAndFile do carry kind, so the target.kind !== 'class' guard inside attachConstructorTargets works correctly. However, if downstream code ever tries to read kind from items of resolved after this call, TypeScript won't catch that access as safe. Widening the targets declaration to include kind?: string would close the gap.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Codegraph Impact Analysis

4 functions changed18 callers affected across 4 files

  • resolveCallTargets in src/domain/graph/builder/call-resolver.ts:264 (15 transitive callers)
  • constructorLocalName in src/domain/graph/resolver/strategy.ts:107 (3 transitive callers)
  • resolveConstructorTarget in src/domain/graph/resolver/strategy.ts:120 (9 transitive callers)
  • attachConstructorTargets in src/domain/graph/resolver/strategy.ts:148 (12 transitive callers)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant