From 801d2900a4bbcb79e84b40a717e56ced36b6ee69 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Wed, 8 Jul 2026 05:06:22 -0600 Subject: [PATCH] fix(resolver): attribute new ClassName() calls to the constructor method `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 --- .../graph/builder/stages/build_edges.rs | 106 +++++++++ src/domain/graph/builder/call-resolver.ts | 9 +- src/domain/graph/resolver/strategy.ts | 105 +++++++++ .../fixtures/csharp/expected-edges.json | 14 ++ .../dynamic-typescript/expected-edges.json | 7 + .../fixtures/javascript/expected-edges.json | 21 ++ .../fixtures/python/expected-edges.json | 28 +++ ...-1892-constructor-call-attribution.test.ts | 209 ++++++++++++++++++ 8 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 tests/integration/issue-1892-constructor-call-attribution.test.ts diff --git a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs index 4131ca9df..18b812e78 100644 --- a/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs +++ b/crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs @@ -985,6 +985,12 @@ fn resolve_exact_global_match<'a>( /// `caller_name` is the enclosing function/method name (e.g. `"Shape.describe"`) used to scope /// `this`/`self`/`super` dispatch to the caller's own class before falling back to a broader scan. /// Mirrors `resolveCallTargets` / `resolveByMethodOrGlobal` in call-resolver.ts. +/// +/// Thin wrapper around `resolve_call_targets_core`: additionally attaches +/// constructor-call attribution (#1892) for bare (no-receiver) calls — see +/// `attach_constructor_targets`. Split out because the core resolver has many +/// early-return tiers, so a single post-processing pass at the call site is +/// simpler than threading the augmentation through every tier. fn resolve_call_targets<'a>( ctx: &EdgeContext<'a>, call: &CallInfo, @@ -993,6 +999,30 @@ fn resolve_call_targets<'a>( type_map: &HashMap<&str, (&str, f64)>, caller_name: &str, imported_original_names: &HashMap<&str, &str>, +) -> Vec<&'a NodeInfo> { + let targets = resolve_call_targets_core( + ctx, call, rel_path, imported_from, type_map, caller_name, imported_original_names, + ); + if call.receiver.is_some() { + return targets; + } + let class_name = imported_original_names + .get(call.name.as_str()) + .copied() + .unwrap_or(call.name.as_str()); + attach_constructor_targets(ctx, targets, class_name) +} + +/// Core multi-strategy call target resolution — see `resolve_call_targets` for +/// the public entry point (which additionally applies constructor attribution). +fn resolve_call_targets_core<'a>( + ctx: &EdgeContext<'a>, + call: &CallInfo, + rel_path: &str, + imported_from: Option<&str>, + type_map: &HashMap<&str, (&str, f64)>, + caller_name: &str, + imported_original_names: &HashMap<&str, &str>, ) -> Vec<&'a NodeInfo> { // Flagged dynamic calls use synthetic names like "". Short-circuit // so they never accidentally match a real symbol via name lookup. @@ -1305,6 +1335,82 @@ fn resolve_call_targets<'a>( Vec::new() } +// ── Constructor-call attribution (#1892) ────────────────────────────────── + +/// Per-language constructor method identifier, keyed by file extension. Used +/// to build the qualified `ClassName.` lookup key that +/// attributes a `new ClassName()` (or bare `ClassName()`, for the keyword-less +/// languages below) call site to the class's own constructor **method**, +/// rather than only the class declaration node it already resolves to. +/// Returns `None` when the extension is unrecognised (or has no extension at +/// all). For Java/C#/Dart/Groovy the constructor's own identifier equals the +/// class name (`class Foo { Foo(...) {} }`), so those arms return +/// `class_name` itself rather than a fixed keyword. Mirrors +/// `CONSTRUCTOR_LOCAL_NAME_BY_EXTENSION` in strategy.ts. +/// +/// Deliberately excludes languages whose extractor does not emit an explicit +/// constructor definition at all (Kotlin, Swift, Scala) or does not track +/// object-construction call sites at all (C++) — for those, the class-node +/// edge already produced by `resolve_call_targets_core` is the only +/// attribution possible. +fn constructor_local_name<'b>(file: &str, class_name: &'b str) -> Option<&'b str> { + let ext = file.rsplit_once('.').map(|(_, e)| e)?; + match ext { + "js" | "mjs" | "cjs" | "jsx" | "ts" | "tsx" | "mts" | "cts" => Some("constructor"), + "py" | "pyi" => Some("__init__"), + "php" | "phtml" => Some("__construct"), + "java" | "cs" | "dart" | "groovy" | "gvy" => Some(class_name), + _ => None, + } +} + +/// Resolve the constructor **method** node for a class target, if the class +/// declares one explicitly. Scoped to the class's own file (`file`) so an +/// unrelated same-named constructor elsewhere can never match. +fn resolve_constructor_target<'a>( + ctx: &EdgeContext<'a>, + file: &str, + class_name: &str, +) -> Option<&'a NodeInfo> { + let local_name = constructor_local_name(file, class_name)?; + let qualified = format!("{}.{}", class_name, local_name); + ctx.nodes_by_name_and_file + .get(&(qualified.as_str(), file)) + .and_then(|v| v.iter().find(|n| n.kind == "method")) + .copied() +} + +/// Additive constructor-call attribution: for every `class`-kind target in +/// `targets`, also resolve that class's own constructor method (if one is +/// explicitly declared) and append it. Mirrors `attachConstructorTargets` in +/// strategy.ts. +/// +/// Additive, not a replacement: the class-node target is always left standing +/// — the DB-driven RTA fallback (incremental rebuilds, see `cha.ts`'s +/// `buildChaContextFromDb`) reads instantiation evidence from `calls` edges +/// targeting class-kind nodes, and a class with no explicit constructor +/// legitimately has nothing else to attribute the call to. +fn attach_constructor_targets<'a>( + ctx: &EdgeContext<'a>, + mut targets: Vec<&'a NodeInfo>, + class_name: &str, +) -> Vec<&'a NodeInfo> { + let mut seen_ids: HashSet = targets.iter().map(|t| t.id).collect(); + let mut extra = Vec::new(); + for target in &targets { + if target.kind != "class" { + continue; + } + if let Some(ctor) = resolve_constructor_target(ctx, target.file.as_str(), class_name) { + if seen_ids.insert(ctor.id) { + extra.push(ctor); + } + } + } + targets.extend(extra); + targets +} + /// Languages where bare `foo()` calls inside a class method are lexically scoped /// to the module, not the class — there is no implicit this/class binding. /// Mirrors `MODULE_SCOPED_BARE_CALL_EXTENSIONS` in call-resolver.ts. diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index d68617723..bfbd3e56c 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -12,6 +12,7 @@ import { CALLABLE_SYMBOL_KINDS } from '../../../shared/kinds.js'; import { computeConfidence, isSameLanguageFamily } from '../resolve.js'; import { + attachConstructorTargets, isModuleScopedLanguage, resolveByGlobal, resolveByReceiver, @@ -322,7 +323,13 @@ export function resolveCallTargets( } } - const resolved = [...(targets ?? [])]; + 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); + } if (resolved.length > 1) { resolved.sort((a, b) => { const confA = computeConfidence(relPath, a.file, importedFrom ?? null); diff --git a/src/domain/graph/resolver/strategy.ts b/src/domain/graph/resolver/strategy.ts index 2d6a09f4d..0ac1ea3e8 100644 --- a/src/domain/graph/resolver/strategy.ts +++ b/src/domain/graph/resolver/strategy.ts @@ -58,6 +58,111 @@ export function isModuleScopedLanguage(relPath: string): boolean { return MODULE_SCOPED_BARE_CALL_EXTENSIONS.has(ext); } +// ── Constructor-call attribution (#1892) ────────────────────────────────── + +/** + * Sentinel meaning "this language's constructor is named identically to its + * class" — Java, C#, Dart, and Groovy all require `class Foo { Foo(...) {} }`; + * the constructor's own identifier is the class name, not a fixed keyword. + */ +const CTOR_NAME_SAME_AS_CLASS = Symbol('ctor-name-same-as-class'); + +/** + * Per-language constructor method identifier, keyed by file extension. Used + * to build the qualified `ClassName.` lookup key that + * attributes a `new ClassName()` (or bare `ClassName()`, for the keyword-less + * languages below) call site to the class's own constructor **method**, + * rather than only the class declaration node it already resolves to (#1892). + * Mirrors `constructor_local_name` in build_edges.rs. + * + * Deliberately excludes languages whose extractor does not emit an explicit + * constructor definition at all (Kotlin, Swift, Scala — verified via their + * extractors' AST walk switches) or does not track object-construction call + * sites at all (C++) — for those, the class-node edge already produced by + * the tiers above is the only attribution possible. + */ +const CONSTRUCTOR_LOCAL_NAME_BY_EXTENSION = new Map< + string, + string | typeof CTOR_NAME_SAME_AS_CLASS +>([ + ['.js', 'constructor'], + ['.mjs', 'constructor'], + ['.cjs', 'constructor'], + ['.jsx', 'constructor'], + ['.ts', 'constructor'], + ['.tsx', 'constructor'], + ['.mts', 'constructor'], + ['.cts', 'constructor'], + ['.py', '__init__'], + ['.pyi', '__init__'], + ['.php', '__construct'], + ['.phtml', '__construct'], + ['.java', CTOR_NAME_SAME_AS_CLASS], + ['.cs', CTOR_NAME_SAME_AS_CLASS], + ['.dart', CTOR_NAME_SAME_AS_CLASS], + ['.groovy', CTOR_NAME_SAME_AS_CLASS], + ['.gvy', CTOR_NAME_SAME_AS_CLASS], +]); + +function constructorLocalName(file: string, className: string): string | null { + const dotIdx = file.lastIndexOf('.'); + if (dotIdx === -1) return null; + const entry = CONSTRUCTOR_LOCAL_NAME_BY_EXTENSION.get(file.slice(dotIdx)); + if (!entry) return null; + return entry === CTOR_NAME_SAME_AS_CLASS ? className : entry; +} + +/** + * Resolve the constructor **method** node for a class target, if the class + * declares one explicitly. Scoped to the class's own file (`classTarget.file`) + * so an unrelated same-named constructor elsewhere can never match. + */ +function resolveConstructorTarget( + lookup: StrategyLookup, + classTarget: { file: string }, + className: string, +): { id: number; file: string; kind?: string } | null { + const ctorLocalName = constructorLocalName(classTarget.file, className); + if (!ctorLocalName) return null; + const found = lookup + .byNameAndFile(`${className}.${ctorLocalName}`, classTarget.file) + .find((n) => n.kind === 'method'); + return found ?? null; +} + +/** + * Additive constructor-call attribution (#1892): for every `class`-kind + * target in `targets`, also resolve that class's own constructor method (if + * one is explicitly declared) and append it. + * + * Additive, not a replacement: the class-node target is always left standing + * — `buildChaContextFromDb`'s RTA fallback (incremental rebuilds, see + * `builder/cha.ts`) reads instantiation evidence from `calls` edges targeting + * class-kind nodes, and a class with no explicit constructor legitimately has + * nothing else to attribute the call to. + * + * Only meaningful for bare (no-receiver) calls — a `new ClassName()` / + * `ClassName()` construction call never carries a receiver — so callers must + * gate on `!call.receiver` before invoking this. + */ +export function attachConstructorTargets( + lookup: StrategyLookup, + targets: readonly T[], + className: string, +): T[] { + const augmented = [...targets]; + const seenIds = new Set(targets.map((t) => t.id)); + for (const target of targets) { + if (target.kind !== 'class') continue; + const ctorTarget = resolveConstructorTarget(lookup, target, className); + if (ctorTarget && !seenIds.has(ctorTarget.id)) { + augmented.push(ctorTarget as T); + seenIds.add(ctorTarget.id); + } + } + return augmented; +} + // ── typeMap entry unwrapping ────────────────────────────────────────────────── /** diff --git a/tests/benchmarks/resolution/fixtures/csharp/expected-edges.json b/tests/benchmarks/resolution/fixtures/csharp/expected-edges.json index c7c149c45..08b3c17e2 100644 --- a/tests/benchmarks/resolution/fixtures/csharp/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/csharp/expected-edges.json @@ -66,6 +66,13 @@ "mode": "constructor", "notes": "new UserService(repo) — constructor with dependency injection" }, + { + "source": { "name": "Program.Main", "file": "Program.cs" }, + "target": { "name": "UserService.UserService", "file": "Service.cs" }, + "kind": "calls", + "mode": "constructor", + "notes": "new UserService(repo) — also attributes to the constructor method itself (#1892)" + }, { "source": { "name": "Program.Main", "file": "Program.cs" }, "target": { "name": "User", "file": "Repository.cs" }, @@ -115,6 +122,13 @@ "mode": "constructor", "notes": "new UserService(repo) — constructor with dependency injection" }, + { + "source": { "name": "Program.RunWithValidation", "file": "Program.cs" }, + "target": { "name": "UserService.UserService", "file": "Service.cs" }, + "kind": "calls", + "mode": "constructor", + "notes": "new UserService(repo) — also attributes to the constructor method itself (#1892)" + }, { "source": { "name": "Program.RunWithValidation", "file": "Program.cs" }, "target": { "name": "User", "file": "Repository.cs" }, diff --git a/tests/benchmarks/resolution/fixtures/dynamic-typescript/expected-edges.json b/tests/benchmarks/resolution/fixtures/dynamic-typescript/expected-edges.json index ff67e575d..72de91217 100644 --- a/tests/benchmarks/resolution/fixtures/dynamic-typescript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/dynamic-typescript/expected-edges.json @@ -17,6 +17,13 @@ "mode": "dynamic", "notes": "Reflect.construct(UserService, args) — reflection kind, resolves to UserService class" }, + { + "source": { "name": "runReflectConstruct", "file": "reflect.ts" }, + "target": { "name": "UserService.constructor", "file": "reflect.ts" }, + "kind": "calls", + "mode": "dynamic", + "notes": "Reflect.construct(UserService, args) — also attributes to the constructor method itself (#1892)" + }, { "source": { "name": "runReflectGetLiteral", "file": "reflect.ts" }, "target": { "name": "greet", "file": "reflect.ts" }, diff --git a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json index 581bfd3c7..bee806617 100644 --- a/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/javascript/expected-edges.json @@ -115,6 +115,13 @@ "mode": "constructor", "notes": "new UserService() — class instantiation tracked as consumption" }, + { + "source": { "name": "directInstantiation", "file": "index.js" }, + "target": { "name": "UserService.constructor", "file": "service.js" }, + "kind": "calls", + "mode": "constructor", + "notes": "new UserService() — also attributes to the constructor method itself (#1892)" + }, { "source": { "name": "UserService.constructor", "file": "service.js" }, "target": { "name": "Logger", "file": "logger.js" }, @@ -122,6 +129,13 @@ "mode": "constructor", "notes": "new Logger('UserService') — class instantiation in constructor" }, + { + "source": { "name": "UserService.constructor", "file": "service.js" }, + "target": { "name": "Logger.constructor", "file": "logger.js" }, + "kind": "calls", + "mode": "constructor", + "notes": "new Logger('UserService') — also attributes to the constructor method itself (#1892)" + }, { "source": { "name": "buildService", "file": "service.js" }, "target": { "name": "UserService", "file": "service.js" }, @@ -129,6 +143,13 @@ "mode": "constructor", "notes": "new UserService() — class instantiation tracked as consumption" }, + { + "source": { "name": "buildService", "file": "service.js" }, + "target": { "name": "UserService.constructor", "file": "service.js" }, + "kind": "calls", + "mode": "constructor", + "notes": "new UserService() — also attributes to the constructor method itself (#1892)" + }, { "source": { "name": "_defProp", "file": "define-property.js" }, "target": { "name": "f1", "file": "define-property.js" }, diff --git a/tests/benchmarks/resolution/fixtures/python/expected-edges.json b/tests/benchmarks/resolution/fixtures/python/expected-edges.json index e036e5b32..aab0f8052 100644 --- a/tests/benchmarks/resolution/fixtures/python/expected-edges.json +++ b/tests/benchmarks/resolution/fixtures/python/expected-edges.json @@ -38,6 +38,13 @@ "mode": "constructor", "notes": "Order('o1', ...) — class instantiation" }, + { + "source": { "name": "run", "file": "main.py" }, + "target": { "name": "Order.__init__", "file": "models.py" }, + "kind": "calls", + "mode": "constructor", + "notes": "Order('o1', ...) — also attributes to the constructor method itself (#1892)" + }, { "source": { "name": "run", "file": "main.py" }, "target": { "name": "Order.validate", "file": "models.py" }, @@ -59,6 +66,13 @@ "mode": "constructor", "notes": "User(user_id, name, email) — class instantiation via import" }, + { + "source": { "name": "UserService.create_user", "file": "service.py" }, + "target": { "name": "User.__init__", "file": "models.py" }, + "kind": "calls", + "mode": "constructor", + "notes": "User(user_id, name, email) — also attributes to the constructor method itself (#1892)" + }, { "source": { "name": "UserService.create_user", "file": "service.py" }, "target": { "name": "User.validate", "file": "models.py" }, @@ -101,12 +115,26 @@ "mode": "constructor", "notes": "UserService(repo) — same-file class instantiation" }, + { + "source": { "name": "build_service", "file": "service.py" }, + "target": { "name": "UserService.__init__", "file": "service.py" }, + "kind": "calls", + "mode": "constructor", + "notes": "UserService(repo) — also attributes to the constructor method itself (#1892)" + }, { "source": { "name": "create_repository", "file": "repository.py" }, "target": { "name": "UserRepository", "file": "repository.py" }, "kind": "calls", "mode": "constructor", "notes": "UserRepository() — same-file class instantiation" + }, + { + "source": { "name": "create_repository", "file": "repository.py" }, + "target": { "name": "UserRepository.__init__", "file": "repository.py" }, + "kind": "calls", + "mode": "constructor", + "notes": "UserRepository() — also attributes to the constructor method itself (#1892)" } ] } diff --git a/tests/integration/issue-1892-constructor-call-attribution.test.ts b/tests/integration/issue-1892-constructor-call-attribution.test.ts new file mode 100644 index 000000000..2a5a2734b --- /dev/null +++ b/tests/integration/issue-1892-constructor-call-attribution.test.ts @@ -0,0 +1,209 @@ +/** + * Regression test for #1892: `new ClassName()` (and bare `ClassName()` for + * languages with no `new` keyword) resolved only to the class declaration + * node, never to the class's own constructor **method** — even when the + * class declares one explicitly. `fn-impact`/`roles --role dead` therefore + * always reported 0 dependents / role `dead` for every constructor in the + * codebase, no matter how many call sites actually constructed the class. + * + * Root cause: the bare-name call site (`{ name: 'Foo' }`, no receiver) always + * matched the class node (stored under the bare name `Foo`), never the + * constructor method (stored under a language-specific qualified name — + * `Foo.constructor` for JS/TS, `Foo.__init__` for Python, `Foo.Foo` for + * Java/C#/Dart/Groovy, whose constructor identifier equals the class name). + * + * Fix: `resolveCallTargets` (call-resolver.ts) / `resolve_call_targets` + * (build_edges.rs) now additionally resolve the class's own constructor + * method and, when found, emit a second `calls` edge to it alongside the + * pre-existing class-node edge (`attachConstructorTargets` / + * `attach_constructor_targets`). The class-node edge is intentionally kept — + * `buildChaContextFromDb`'s RTA fallback (incremental rebuilds) reads + * instantiation evidence from `calls` edges targeting class-kind nodes. + * + * A class with no explicit constructor (`Bar`/`Baz`/`Qux` below) must still + * resolve only to the class node — there is no method to attribute the call + * to, and the fix must not fabricate one. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; +import type { EngineMode } from '../../src/types.js'; + +const JS_FIXTURE = ` +class Foo { + constructor() { + this.value = 1; + } +} + +class Bar {} + +const foo = new Foo(); +const bar = new Bar(); +`; + +// Each construction is wrapped in its own top-level function rather than a +// bare module-level assignment: two SCREAMING_CASE module constants without +// individual endLine bounds collide in findEnclosingBinding's widest-span +// tie-break (see #2027, filed separately — unrelated to this fix), which +// would make this fixture assert the wrong caller name. +const PY_FIXTURE = ` +class Baz: + def __init__(self): + self.value = 1 + +class Qux: + pass + +def make_baz(): + return Baz() + +def make_qux(): + return Qux() +`; + +const JAVA_FIXTURE = ` +class Quux { + Quux() { + } +} + +class Corge { +} + +class Wrapper { + void run() { + Quux quux = new Quux(); + Corge corge = new Corge(); + } +} +`; + +interface CallEdgeRow { + src: string; + srcKind: string; + tgt: string; + tgtKind: string; +} + +function readCallEdges(dbPath: string): CallEdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n1.kind AS srcKind, n2.name AS tgt, n2.kind AS tgtKind + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'calls' + ORDER BY n1.name, n2.name`, + ) + .all() as CallEdgeRow[]; + } finally { + db.close(); + } +} + +function runScenario(engine: EngineMode): void { + describe(`constructor-call attribution (#1892) — ${engine}`, () => { + let dir: string; + let edges: CallEdgeRow[]; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1892-${engine}-`)); + fs.writeFileSync(path.join(dir, 'repro.js'), JS_FIXTURE); + fs.writeFileSync(path.join(dir, 'repro.py'), PY_FIXTURE); + fs.writeFileSync(path.join(dir, 'Repro.java'), JAVA_FIXTURE); + await buildGraph(dir, { engine, incremental: false, skipRegistry: true }); + edges = readCallEdges(path.join(dir, '.codegraph', 'graph.db')); + }, 30_000); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('JS/TS: new Foo() resolves to both Foo (class) and Foo.constructor (method)', () => { + expect(edges).toContainEqual({ + src: 'foo', + srcKind: 'constant', + tgt: 'Foo', + tgtKind: 'class', + }); + expect(edges).toContainEqual({ + src: 'foo', + srcKind: 'constant', + tgt: 'Foo.constructor', + tgtKind: 'method', + }); + }); + + it('JS/TS: new Bar() resolves only to Bar (class) — no explicit constructor to attribute to', () => { + expect(edges).toContainEqual({ + src: 'bar', + srcKind: 'constant', + tgt: 'Bar', + tgtKind: 'class', + }); + expect(edges.some((e) => e.src === 'bar' && e.tgt === 'Bar.constructor')).toBe(false); + }); + + it('Python: Baz() resolves to both Baz (class) and Baz.__init__ (method)', () => { + expect(edges).toContainEqual({ + src: 'make_baz', + srcKind: 'function', + tgt: 'Baz', + tgtKind: 'class', + }); + expect(edges).toContainEqual({ + src: 'make_baz', + srcKind: 'function', + tgt: 'Baz.__init__', + tgtKind: 'method', + }); + }); + + it('Python: Qux() resolves only to Qux (class) — no explicit __init__ to attribute to', () => { + expect(edges).toContainEqual({ + src: 'make_qux', + srcKind: 'function', + tgt: 'Qux', + tgtKind: 'class', + }); + expect(edges.some((e) => e.src === 'make_qux' && e.tgt === 'Qux.__init__')).toBe(false); + }); + + it('Java: new Quux() resolves to both Quux (class) and Quux.Quux (constructor method)', () => { + expect(edges).toContainEqual({ + src: 'Wrapper.run', + srcKind: 'method', + tgt: 'Quux', + tgtKind: 'class', + }); + expect(edges).toContainEqual({ + src: 'Wrapper.run', + srcKind: 'method', + tgt: 'Quux.Quux', + tgtKind: 'method', + }); + }); + + 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'); +});