Skip to content

Commit b524a8e

Browse files
committed
fix: resolve merge conflicts with main
2 parents 5f00b7d + 9037c13 commit b524a8e

13 files changed

Lines changed: 330 additions & 99 deletions

File tree

crates/codegraph-core/src/extractors/csharp.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,27 @@ fn match_csharp_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols,
455455
"variable_declaration" => {
456456
let type_node = node.child_by_field_name("type").or_else(|| node.child(0));
457457
if let Some(type_node) = type_node {
458-
if type_node.kind() != "var_keyword" && type_node.kind() != "implicit_type" {
458+
if type_node.kind() == "implicit_type" {
459+
// var x = new Foo() — infer type from object_creation_expression initializer
460+
for i in 0..node.child_count() {
461+
if let Some(declarator) = node.child(i) {
462+
if declarator.kind() != "variable_declarator" { continue; }
463+
let name_node = declarator.child_by_field_name("name")
464+
.or_else(|| declarator.child(0));
465+
let Some(name_node) = name_node else { continue };
466+
if name_node.kind() != "identifier" { continue; }
467+
let Some(obj_creation) = find_child(&declarator, "object_creation_expression") else { continue };
468+
let Some(ctor_type_node) = obj_creation.child_by_field_name("type") else { continue };
469+
if let Some(ctor_type) = extract_csharp_type_name(&ctor_type_node, source) {
470+
symbols.type_map.push(TypeMapEntry {
471+
name: node_text(&name_node, source).to_string(),
472+
type_name: ctor_type.to_string(),
473+
confidence: 1.0,
474+
});
475+
}
476+
}
477+
}
478+
} else if type_node.kind() != "var_keyword" {
459479
if let Some(type_name) = extract_csharp_type_name(&type_node, source) {
460480
for i in 0..node.child_count() {
461481
if let Some(child) = node.child(i) {

src/domain/graph/builder/call-resolver.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,28 @@ export interface CallNodeLookup {
2323

2424
export const RECEIVER_KINDS = new Set(['class', 'struct', 'interface', 'type', 'module']);
2525

26+
/**
27+
* Languages where bare `foo()` calls inside a class method are lexically scoped
28+
* to the module, not the class — there is no implicit this/class binding.
29+
* For these languages, the same-class fallback must not run for bare (no-receiver)
30+
* calls that found no exact same-file match.
31+
*/
32+
const MODULE_SCOPED_BARE_CALL_EXTENSIONS = new Set([
33+
'.js',
34+
'.mjs',
35+
'.cjs',
36+
'.jsx',
37+
'.ts',
38+
'.tsx',
39+
'.mts',
40+
'.cts',
41+
]);
42+
43+
export function isModuleScopedLanguage(relPath: string): boolean {
44+
const ext = relPath.slice(relPath.lastIndexOf('.'));
45+
return MODULE_SCOPED_BARE_CALL_EXTENSIONS.has(ext);
46+
}
47+
2648
// ── Shared resolution functions ──────────────────────────────────────────
2749

2850
export function findCaller(
@@ -94,12 +116,15 @@ export function resolveByMethodOrGlobal(
94116
: (typeEntry as { type?: string }).type
95117
: null;
96118

97-
// Handle inline new-expression receivers: `(new Foo).bar()` or `(new Foo()).bar()`.
98-
// extractReceiverName returns the raw node text for non-identifier nodes, so `(new A).t()`
99-
// produces receiver='(new A)'. Extract the constructor name directly.
100-
// The regex intentionally restricts to uppercase-initial names ([A-Z_$]) as a heuristic
101-
// to distinguish constructors (PascalCase) from regular functions — avoiding false positives
102-
// on `(new xmlParser()).parse()` style calls which are rare in practice.
119+
// Belt-and-suspenders fallback for inline new-expression receivers that
120+
// extractReceiverName did not normalise (e.g. raw text leaked from an
121+
// unhandled AST node type). extractReceiverName already handles the common
122+
// `new_expression` / `parenthesized_expression(new_expression)` shapes by
123+
// returning the constructor name directly, so this branch is exercised only
124+
// by future node types or constructs that fall through to the raw-text path.
125+
// The uppercase-initial restriction ([A-Z_$]) is a heuristic to distinguish
126+
// constructors (PascalCase) from regular functions and avoids false positives
127+
// on `(new xmlParser()).parse()` style calls.
103128
if (!typeName && call.receiver) {
104129
const m = /^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/.exec(call.receiver);
105130
if (m?.[1]) typeName = m[1];
@@ -203,7 +228,14 @@ export function resolveByMethodOrGlobal(
203228
// Also covers no-receiver calls inside class methods, e.g. `IsValidEmail(x)` inside
204229
// `Validators.ValidateUser` → try `Validators.IsValidEmail` (C#/Java static siblings).
205230
// This seeds the initial edge that runChaPostPass later expands to subclass overrides.
206-
if (callerName) {
231+
//
232+
// For JS/TS, bare (no-receiver) calls are module-scoped — there is no implicit class
233+
// binding. Skip the same-class fallback for bare calls in those languages to prevent
234+
// false positives (e.g. `flush()` inside `Processor.run` must not resolve to
235+
// `Processor.flush`). this.method() calls are unaffected: they still reach the fallback
236+
// because `call.receiver === 'this'` is truthy, not a bare call.
237+
const isBareCall = !call.receiver;
238+
if (callerName && !(isBareCall && isModuleScopedLanguage(relPath))) {
207239
const dotIdx = callerName.lastIndexOf('.');
208240
if (dotIdx > -1) {
209241
// Extract only the segment immediately before the method name so that

src/domain/graph/builder/stages/build-edges.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { enrichTypeMapWithTsc } from '../../resolver/ts-resolver.js';
3030
import {
3131
type CallNodeLookup,
3232
findCaller,
33+
isModuleScopedLanguage,
3334
resolveCallTargets,
3435
resolveReceiverEdge,
3536
} from '../call-resolver.js';
@@ -1340,9 +1341,35 @@ function buildFileCallEdges(
13401341
// not the enclosing class, so qualifying with the child class name would
13411342
// produce a false edge when the child also defines a same-named method.
13421343
if (targets.length === 0 && call.receiver === 'this' && caller.callerName != null) {
1343-
const dotIdx = caller.callerName.indexOf('.');
1344-
if (dotIdx > 0) {
1345-
const className = caller.callerName.slice(0, dotIdx);
1344+
const lastDot = caller.callerName.lastIndexOf('.');
1345+
if (lastDot > 0) {
1346+
const prevDot = caller.callerName.lastIndexOf('.', lastDot - 1);
1347+
const className = caller.callerName.slice(prevDot + 1, lastDot);
1348+
const qualifiedName = `${className}.${call.name}`;
1349+
const qualified = lookup
1350+
.byNameAndFile(qualifiedName, relPath)
1351+
.filter((n) => n.kind === 'method');
1352+
if (qualified.length > 0) {
1353+
targets = qualified;
1354+
}
1355+
}
1356+
}
1357+
1358+
// Same-class bare-call fallback: when a no-receiver call can't be resolved
1359+
// globally, try the caller's own class as a qualifier. Handles C# static
1360+
// sibling calls: `IsValidEmail()` inside `Validators.ValidateUser` resolves
1361+
// to `Validators.IsValidEmail`. Skipped for JS/TS where bare calls are
1362+
// module-scoped, not class-scoped.
1363+
if (
1364+
targets.length === 0 &&
1365+
!call.receiver &&
1366+
caller.callerName != null &&
1367+
!isModuleScopedLanguage(relPath)
1368+
) {
1369+
const lastDot = caller.callerName.lastIndexOf('.');
1370+
if (lastDot > 0) {
1371+
const prevDot = caller.callerName.lastIndexOf('.', lastDot - 1);
1372+
const className = caller.callerName.slice(prevDot + 1, lastDot);
13461373
const qualifiedName = `${className}.${call.name}`;
13471374
const qualified = lookup
13481375
.byNameAndFile(qualifiedName, relPath)

src/domain/graph/builder/stages/native-orchestrator.ts

Lines changed: 20 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -402,9 +402,9 @@ async function runPostNativeAnalysis(
402402
* Note: `this`/`super` dispatch is handled separately by `runPostNativeThisDispatch`,
403403
* which WASM-re-parses JS/TS files to obtain raw call site receiver info.
404404
*
405-
* Returns the set of target node IDs for newly inserted CHA edges so the caller
406-
* can re-classify roles for the affected implementation files. An empty set
407-
* means no edges were added and role re-classification is unnecessary.
405+
* Returns the count of newly inserted CHA edges so the caller can determine
406+
* whether a full role re-classification is needed. Zero means no edges were
407+
* added and role re-classification is unnecessary.
408408
*/
409409
function runPostNativeCha(db: BetterSqlite3Database): number {
410410
// Fast guard: no hierarchy edges → no CHA work
@@ -1607,37 +1607,9 @@ export async function tryNativeOrchestrator(
16071607
}
16081608

16091609
// Phase 8.5: expand CHA call edges (interface dispatch → concrete implementations).
1610-
// The Rust orchestrator ran role classification BEFORE this post-pass, so without
1611-
// a re-run the newly-called implementor methods stay classified as `dead-ffi`.
1612-
//
1613-
// CHA also changes the global fan-out distribution (callee files gain fan_in, and
1614-
// new edges shift the median). A full re-classification is required — not just the
1615-
// callee files — because the median shift can change roles in unrelated files whose
1616-
// fan-out sits near the old median. (Example: a method that called two siblings
1617-
// pre-CHA might be near the median, but post-CHA the median is higher, changing
1618-
// its role from utility → core.) Using an incremental pass with a stale median
1619-
// cache would produce incorrect roles outside the CHA-affected file set.
1620-
//
1621-
// Performance: classifyNodeRoles is O(all_nodes). For most repos this is sub-100ms;
1622-
// on very large codebases (100k+ nodes) it may add a few hundred ms per build.
1623-
// If this becomes a bottleneck, consider a two-pass strategy: incremental first
1624-
// (fast, slightly inaccurate), then full only when the median shifts by >N%.
1610+
// Returns the count of newly inserted edges; used to determine whether
1611+
// a full role re-classification is needed after all edge-writing post-passes complete.
16251612
const chaEdgeCount = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database);
1626-
if (chaEdgeCount > 0) {
1627-
try {
1628-
const db = ctx.db as unknown as BetterSqlite3Database;
1629-
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1630-
classifyNodeRoles: (
1631-
db: BetterSqlite3Database,
1632-
changedFiles?: string[] | null,
1633-
) => Record<string, number>;
1634-
};
1635-
classifyNodeRoles(db);
1636-
debug(`CHA post-pass: full role re-classification after ${chaEdgeCount} new CHA edges`);
1637-
} catch (err) {
1638-
debug(`CHA post-pass role re-classification failed: ${toErrorMessage(err)}`);
1639-
}
1640-
}
16411613

16421614
// Function-as-object-property post-pass: the Rust engine does not yet recognise
16431615
// `fn.method = function() {}` patterns. Re-parse only those JS/TS files via
@@ -1659,51 +1631,23 @@ export async function tryNativeOrchestrator(
16591631
!!result.isFullBuild,
16601632
);
16611633

1662-
// Re-classify roles for methods that gained incoming this/super dispatch edges.
1663-
// The Rust orchestrator classifies roles BEFORE this post-pass, so target methods
1664-
// (e.g. Animal.speak, ConcreteWorker.prepare) that had no callers at Rust time
1665-
// are classified `dead` or `dead-ffi`. Inserting the new call edges does not
1666-
// automatically update those role labels — without a re-run the stale labels
1667-
// propagate to dead-code detection and API boundary analysis.
1668-
if (thisDispatchTargetIds.size > 0) {
1634+
// Full role re-classification after JS edge-writing post-passes.
1635+
// The Rust orchestrator classifies roles before these post-passes (CHA,
1636+
// this-dispatch) add edges, so the Rust-computed roles and the cached
1637+
// fan-out medians are stale. A full re-classification ensures the final
1638+
// roles reflect the true fan-in/out with all edges in place.
1639+
if (chaEdgeCount > 0 || thisDispatchTargetIds.size > 0) {
16691640
try {
1670-
const db = ctx.db as unknown as BetterSqlite3Database;
1671-
const idArray = Array.from(thisDispatchTargetIds);
1672-
const CHUNK_SIZE = 500;
1673-
const seenFiles = new Set<string>();
1674-
const affectedFiles: Array<{ file: string }> = [];
1675-
for (let i = 0; i < idArray.length; i += CHUNK_SIZE) {
1676-
const chunk = idArray.slice(i, i + CHUNK_SIZE);
1677-
const placeholders = chunk.map(() => '?').join(',');
1678-
const rows = db
1679-
.prepare(
1680-
`SELECT DISTINCT file FROM nodes WHERE id IN (${placeholders}) AND file IS NOT NULL`,
1681-
)
1682-
.all(...chunk) as Array<{ file: string }>;
1683-
for (const row of rows) {
1684-
if (!seenFiles.has(row.file)) {
1685-
seenFiles.add(row.file);
1686-
affectedFiles.push(row);
1687-
}
1688-
}
1689-
}
1690-
if (affectedFiles.length > 0) {
1691-
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1692-
classifyNodeRoles: (
1693-
db: BetterSqlite3Database,
1694-
changedFiles?: string[] | null,
1695-
) => Record<string, number>;
1696-
};
1697-
classifyNodeRoles(
1698-
db,
1699-
affectedFiles.map((r) => r.file),
1700-
);
1701-
debug(
1702-
`this/super dispatch post-pass: re-classified roles for ${affectedFiles.length} target file(s)`,
1703-
);
1704-
}
1641+
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1642+
classifyNodeRoles: (
1643+
db: BetterSqlite3Database,
1644+
changedFiles?: string[] | null,
1645+
) => Record<string, number>;
1646+
};
1647+
classifyNodeRoles(ctx.db as unknown as BetterSqlite3Database, null);
1648+
debug(`Post-pass full role re-classification complete`);
17051649
} catch (err) {
1706-
debug(`this/super dispatch post-pass role re-classification failed: ${toErrorMessage(err)}`);
1650+
debug(`Post-pass full role re-classification failed: ${toErrorMessage(err)}`);
17071651
}
17081652
}
17091653

src/extractors/csharp.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -332,16 +332,34 @@ function extractCSharpTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void
332332
/** Extract type info from a variable_declaration node (local vars with explicit types). */
333333
function handleCSharpVarDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
334334
const typeNode = node.childForFieldName('type') || node.child(0);
335-
if (!typeNode || typeNode.type === 'var_keyword') return;
335+
if (!typeNode) return;
336+
337+
if (typeNode.type === 'implicit_type') {
338+
// var x = new Foo() — infer type from object_creation_expression initializer
339+
if (!ctx.typeMap) return;
340+
for (let i = 0; i < node.childCount; i++) {
341+
const child = node.child(i);
342+
if (child?.type !== 'variable_declarator') continue;
343+
const nameNode = child.childForFieldName('name') || child.child(0);
344+
if (nameNode?.type !== 'identifier') continue;
345+
const objCreation = findChild(child, 'object_creation_expression');
346+
if (!objCreation) continue;
347+
const ctorTypeNode = objCreation.childForFieldName('type');
348+
if (!ctorTypeNode) continue;
349+
const ctorType = extractCSharpTypeName(ctorTypeNode);
350+
if (ctorType) setTypeMapEntry(ctx.typeMap, nameNode.text, ctorType, 1.0);
351+
}
352+
return;
353+
}
354+
336355
const typeName = extractCSharpTypeName(typeNode);
337356
if (!typeName) return;
338357
for (let i = 0; i < node.childCount; i++) {
339358
const child = node.child(i);
340359
if (child?.type !== 'variable_declarator') continue;
341360
const nameNode = child.childForFieldName('name') || child.child(0);
342-
if (nameNode && nameNode.type === 'identifier' && ctx.typeMap) {
343-
setTypeMapEntry(ctx.typeMap, nameNode.text, typeName, 0.9);
344-
}
361+
if (nameNode?.type !== 'identifier' || !ctx.typeMap) continue;
362+
if (typeName) setTypeMapEntry(ctx.typeMap, nameNode.text, typeName, 0.9);
345363
}
346364
}
347365

src/extractors/javascript.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2676,6 +2676,25 @@ function extractReceiverName(objNode: TreeSitterNode | null): string | undefined
26762676
if (!objNode) return undefined;
26772677
const t = objNode.type;
26782678
if (t === 'identifier' || t === 'this' || t === 'super') return objNode.text;
2679+
// `(new Foo(...)).method()` — extract the constructor name so the resolver can
2680+
// look up `Foo.method` directly without relying on a text-based regex heuristic.
2681+
if (t === 'new_expression') {
2682+
const name = extractNewExprTypeName(objNode);
2683+
if (name) return name;
2684+
}
2685+
if (t === 'parenthesized_expression') {
2686+
// Only one level of parentheses is unwrapped here. Doubly-nested parens
2687+
// (e.g. `((new Dog())).bark()`) and cast expressions inside parens
2688+
// (e.g. `(new Dog() as Animal).bark()`) fall through to raw-text handling
2689+
// below and are caught by the regex fallback in call-resolver.ts.
2690+
for (let i = 0; i < objNode.childCount; i++) {
2691+
const child = objNode.child(i);
2692+
if (child?.type === 'new_expression') {
2693+
const name = extractNewExprTypeName(child);
2694+
if (name) return name;
2695+
}
2696+
}
2697+
}
26792698
return objNode.text;
26802699
}
26812700

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Regression guard: bare function calls in JS class methods must NOT resolve
2+
// to same-named class methods. In JS/TS, bare foo() is lexically scoped to
3+
// the module, not the class — there is no implicit this binding on bare calls.
4+
//
5+
// If the call.receiver guard in resolveByMethodOrGlobal (call-resolver.ts) is
6+
// ever removed, the resolver would incorrectly emit Processor.run → Processor.flush
7+
// (a false positive). The 1.0 precision floor on the JS fixture catches that
8+
// regression immediately.
9+
10+
export function processData(x) {
11+
return x * 2;
12+
}
13+
14+
export class Processor {
15+
run(x) {
16+
processData(x); // same-file module-level function — resolves correctly
17+
flush(); // bare call; no module-level 'flush' in scope — must NOT resolve to Processor.flush
18+
}
19+
20+
flush() {} // Processor.flush exists; bare flush() in run() must not target it
21+
}

tests/benchmarks/resolution/fixtures/javascript/expected-edges.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,13 @@
289289
"kind": "calls",
290290
"mode": "receiver-typed",
291291
"notes": "this.service.doB() — receiver-typed via ClassB.service = new ServiceB() (class-scoped typeMap key prevents collision with ClassA.service)"
292+
},
293+
{
294+
"source": { "name": "Processor.run", "file": "class-scope.js" },
295+
"target": { "name": "processData", "file": "class-scope.js" },
296+
"kind": "calls",
297+
"mode": "same-file",
298+
"notes": "Bare call to same-file module-level function — regression guard: bare flush() in run() must NOT resolve to Processor.flush (class-scoped lookup must be receiver-gated)"
292299
}
293300
]
294301
}

0 commit comments

Comments
 (0)