Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions crates/codegraph-core/src/domain/graph/builder/stages/build_edges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 "<dynamic:eval>". Short-circuit
// so they never accidentally match a real symbol via name lookup.
Expand Down Expand Up @@ -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.<ctorLocalName>` 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<u32> = 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.
Expand Down
9 changes: 8 additions & 1 deletion src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { CALLABLE_SYMBOL_KINDS } from '../../../shared/kinds.js';
import { computeConfidence, isSameLanguageFamily } from '../resolve.js';
import {
attachConstructorTargets,
isModuleScopedLanguage,
resolveByGlobal,
resolveByReceiver,
Expand Down Expand Up @@ -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);
}
Comment on lines +326 to +332

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

if (resolved.length > 1) {
resolved.sort((a, b) => {
const confA = computeConfidence(relPath, a.file, importedFrom ?? null);
Expand Down
105 changes: 105 additions & 0 deletions src/domain/graph/resolver/strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<ctorLocalName>` 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;
Comment on lines +110 to +111

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

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<T extends { id: number; file: string; kind?: string }>(
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 ──────────────────────────────────────────────────

/**
Expand Down
14 changes: 14 additions & 0 deletions tests/benchmarks/resolution/fixtures/csharp/expected-edges.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,20 +115,41 @@
"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" },
"kind": "calls",
"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" },
"kind": "calls",
"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" },
Expand Down
Loading
Loading