Skip to content

Commit 7c0682e

Browse files
committed
fix: merge origin/main into feat/super-dispatch-1377
2 parents 19bec64 + ddfc14c commit 7c0682e

23 files changed

Lines changed: 1083 additions & 143 deletions

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,22 @@ export function resolveByMethodOrGlobal(
7272
const effectiveReceiver = call.receiver.startsWith('this.')
7373
? call.receiver.slice('this.'.length)
7474
: call.receiver;
75-
const typeEntry =
75+
// For this.prop receivers, also try the class-scoped key (ClassName.prop) seeded by
76+
// handlePropWriteTypeMap — prevents false edges when multiple classes define the same
77+
// property name (issue #1323).
78+
let typeEntry =
7679
typeMap.get(effectiveReceiver) ??
7780
typeMap.get(call.receiver) ??
81+
// Phase 8.3f: callee-scoped rest-param key (`callee::restName`) to avoid
82+
// same-name rest-binding collision across functions in the same file (#1358).
7883
(callerName ? typeMap.get(`${callerName}::${effectiveReceiver}`) : undefined);
84+
if (!typeEntry && call.receiver.startsWith('this.') && callerName) {
85+
const dotIdx = callerName.lastIndexOf('.');
86+
if (dotIdx > -1) {
87+
const callerClass = callerName.slice(0, dotIdx);
88+
typeEntry = typeMap.get(`${callerClass}.${effectiveReceiver}`);
89+
}
90+
}
7991
let typeName = typeEntry
8092
? typeof typeEntry === 'string'
8193
? typeEntry
@@ -186,7 +198,11 @@ export function resolveByMethodOrGlobal(
186198
if (call.receiver && callerName) {
187199
const dotIdx = callerName.lastIndexOf('.');
188200
if (dotIdx > -1) {
189-
const callerClass = callerName.slice(0, dotIdx);
201+
// Extract only the segment immediately before the method name so that
202+
// 'Namespace.ClassName.method' yields 'ClassName', not 'Namespace.ClassName'.
203+
// Symbols are stored under their bare class name, not their qualified path.
204+
const prevDot = callerName.lastIndexOf('.', dotIdx - 1);
205+
const callerClass = callerName.slice(prevDot + 1, dotIdx);
190206
const qualifiedName = `${callerClass}.${call.name}`;
191207
const sameClass = lookup
192208
.byName(qualifiedName)

src/domain/graph/builder/incremental.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,7 @@ function buildCallEdges(
516516
relPath,
517517
importedNames,
518518
typeMap,
519+
caller.callerName,
519520
);
520521

521522
for (const t of targets) {

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

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,51 @@ async function runPostNativePrototypeMethods(
669669

670670
db.transaction(() => batchInsertNodes(db, newNodeRows))();
671671

672+
// ── Caller-only second pass (#1371) ──────────────────────────────────────
673+
// `wasmResults` only covers `protoFiles` (definition files). A file that
674+
// only *calls* a newly-inserted method (e.g. `f.method()`) was excluded from
675+
// the pre-filter, so its call edges to the new nodes are silently dropped.
676+
// After node insertion we know the method name suffixes; text-search the
677+
// remaining JS/TS files and WASM-parse any that contain a matching call.
678+
const newMethodSuffixes = new Set(
679+
newDefs.map((d) => {
680+
const dotIdx = d.name.indexOf('.');
681+
return dotIdx !== -1 ? d.name.slice(dotIdx + 1) : d.name;
682+
}),
683+
);
684+
685+
let mergedWasmResults = wasmResults;
686+
if (newMethodSuffixes.size > 0) {
687+
// Pre-compile patterns once — avoids re-compiling up to newMethodSuffixes.size
688+
// regexes on every file in the scan loop.
689+
const suffixPatterns = [...newMethodSuffixes].map((m) => {
690+
const escaped = m.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
691+
return new RegExp(`\\.${escaped}\\s*\\(`);
692+
});
693+
const protoFileSet = new Set(protoFiles);
694+
const callerCandidateAbs: string[] = [];
695+
for (const relPath of jsFiles) {
696+
if (protoFileSet.has(relPath)) continue; // already parsed in first pass
697+
try {
698+
const content = readFileSafe(path.join(rootDir, relPath));
699+
const matchesAny = suffixPatterns.some((re) => re.test(content));
700+
if (matchesAny) callerCandidateAbs.push(path.join(rootDir, relPath));
701+
} catch {
702+
/* skip unreadable files */
703+
}
704+
}
705+
if (callerCandidateAbs.length > 0) {
706+
try {
707+
const callerWasmResults = await parseFilesWasmForBackfill(callerCandidateAbs, rootDir);
708+
if (callerWasmResults.size > 0) {
709+
mergedWasmResults = new Map([...wasmResults, ...callerWasmResults]);
710+
}
711+
} catch (e) {
712+
debug(`runPostNativePrototypeMethods: caller-only WASM parse failed: ${toErrorMessage(e)}`);
713+
}
714+
}
715+
}
716+
672717
// Build a name → node lookup from all DB nodes (including newly inserted ones).
673718
type NodeEntry = { id: number; file: string; kind: string };
674719
const byNameMap = new Map<string, NodeEntry[]>();
@@ -716,14 +761,13 @@ async function runPostNativePrototypeMethods(
716761
// zero benefit and could OOM on large repositories.
717762
const seenByPair = new Set<string>();
718763

719-
// Resolve call edges in every file — not just those that define new func-prop
720-
// methods. A caller in app.js calling a method defined in lib.js
721-
// would be silently missed if we only scanned definition files.
722-
// The newNodeIds guard inside the loop already prevents duplicate edges.
764+
// Resolve call edges across all parsed files (definition files + caller-only
765+
// files discovered in the second WASM pass above). The newNodeIds guard inside
766+
// the loop prevents emitting duplicate edges for nodes the Rust engine already built.
723767
const newEdgeRows: unknown[][] = [];
724768
const fileNodeStmt = db.prepare(`SELECT id FROM nodes WHERE kind = 'file' AND file = ?`);
725769

726-
for (const [relPath, symbols] of wasmResults) {
770+
for (const [relPath, symbols] of mergedWasmResults) {
727771
const fileNodeRow = fileNodeStmt.get(relPath) as { id: number } | undefined;
728772
if (!fileNodeRow) continue;
729773

@@ -734,14 +778,32 @@ async function runPostNativePrototypeMethods(
734778

735779
const caller = findCaller(lookup, call, symbols.definitions ?? [], relPath, fileNodeRow);
736780

737-
const targets = resolveByMethodOrGlobal(
781+
let targets = resolveByMethodOrGlobal(
738782
lookup,
739783
call,
740784
relPath,
741785
typeMap as Map<string, unknown>,
742786
caller.callerName,
743787
);
744788

789+
// Direct receiver.method fallback: caller-only files often lack typeMap entries
790+
// for the receiver (e.g. `f.process()` where `f` isn't declared in the file).
791+
// Try qualified-name lookup scoped to newly-inserted nodes to avoid false positives.
792+
// Note: `call.receiver` is always truthy here — the `if (!call.receiver) continue`
793+
// guard above ensures we never reach this point with a falsy receiver.
794+
if (
795+
targets.length === 0 &&
796+
call.receiver !== 'this' &&
797+
call.receiver !== 'super' &&
798+
call.receiver !== 'self'
799+
) {
800+
const qualifiedName = `${call.receiver}.${call.name}`;
801+
const direct = lookup
802+
.byName(qualifiedName)
803+
.filter((n) => n.kind === 'method' && newNodeIds.has(n.id));
804+
if (direct.length > 0) targets = direct;
805+
}
806+
745807
for (const t of targets) {
746808
// Only emit edges to newly-inserted func-prop nodes to avoid
747809
// duplicating edges the Rust engine already built.

src/domain/graph/resolver/points-to.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,29 @@ export function buildPointsToMap(
185185
// `function f({ ...rest }) {}` + `f(obj)` + `const obj = { prop: fn }` →
186186
// seed pts["rest.prop"] = {"fn"} so that `rest.prop()` resolves to `fn`.
187187
if (objectRestParamBindings && objectPropBindings && paramBindings) {
188+
// Index paramBindings: "callee::argIndex" → argName[] (O(|paramBindings|) build,
189+
// O(1) lookup — avoids scanning paramBindings for each rest binding).
190+
const paramByCalleeIdx = new Map<string, string[]>();
191+
for (const { callee, argIndex, argName } of paramBindings) {
192+
const k = `${callee}::${argIndex}`;
193+
const list = paramByCalleeIdx.get(k);
194+
if (list) list.push(argName);
195+
else paramByCalleeIdx.set(k, [argName]);
196+
}
197+
198+
// Index objectPropBindings: objectName → {propName, valueName}[]
199+
const propsByObject = new Map<string, Array<{ propName: string; valueName: string }>>();
200+
for (const { objectName, propName, valueName } of objectPropBindings) {
201+
const list = propsByObject.get(objectName);
202+
if (list) list.push({ propName, valueName });
203+
else propsByObject.set(objectName, [{ propName, valueName }]);
204+
}
205+
188206
for (const { callee, restName, argIndex } of objectRestParamBindings) {
189-
for (const { callee: pbCallee, argIndex: pbArgIdx, argName } of paramBindings) {
190-
if (pbCallee !== callee || pbArgIdx !== argIndex) continue;
191-
for (const { objectName, propName, valueName } of objectPropBindings) {
192-
if (objectName !== argName) continue;
207+
const argNames = paramByCalleeIdx.get(`${callee}::${argIndex}`) ?? [];
208+
for (const argName of argNames) {
209+
const props = propsByObject.get(argName) ?? [];
210+
for (const { propName, valueName } of props) {
193211
if (!definitionNames.has(valueName) && !importedNames.has(valueName)) continue;
194212
const key = `${restName}.${propName}`;
195213
if (!pts.has(key)) pts.set(key, new Set());

src/extractors/javascript.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1741,23 +1741,41 @@ function extractTypeMapWalk(
17411741
callAssignments?: CallAssignment[],
17421742
fnRefBindings?: FnRefBinding[],
17431743
): void {
1744-
function walk(node: TreeSitterNode, depth: number): void {
1744+
function walk(node: TreeSitterNode, depth: number, currentClass: string | null): void {
17451745
if (depth >= MAX_WALK_DEPTH) return;
17461746
const t = node.type;
1747+
if (t === 'class_declaration' || t === 'abstract_class_declaration') {
1748+
const nameNode = node.childForFieldName('name');
1749+
const className = nameNode?.text ?? null;
1750+
for (let i = 0; i < node.childCount; i++) {
1751+
walk(node.child(i)!, depth + 1, className);
1752+
}
1753+
return;
1754+
}
1755+
// Class expressions (e.g. `const Foo = class Bar { ... }`): the expression-internal
1756+
// name (`Bar`) is never visible to the resolver, which derives callerClass from the
1757+
// binding name (`Foo`). Walking with null preserves the pre-fix `this.prop` fallback
1758+
// so the second lookup in resolveByMethodOrGlobal still finds the entry.
1759+
if (t === 'class') {
1760+
for (let i = 0; i < node.childCount; i++) {
1761+
walk(node.child(i)!, depth + 1, null);
1762+
}
1763+
return;
1764+
}
17471765
if (t === 'variable_declarator') {
17481766
handleVarDeclaratorTypeMap(node, typeMap, returnTypeMap, callAssignments, fnRefBindings);
17491767
} else if (t === 'required_parameter' || t === 'optional_parameter') {
17501768
handleParamTypeMap(node, typeMap);
17511769
} else if (t === 'assignment_expression') {
1752-
handlePropWriteTypeMap(node, typeMap);
1770+
handlePropWriteTypeMap(node, typeMap, currentClass);
17531771
} else if (t === 'call_expression') {
17541772
handleDefinePropertyTypeMap(node, typeMap);
17551773
}
17561774
for (let i = 0; i < node.childCount; i++) {
1757-
walk(node.child(i)!, depth + 1);
1775+
walk(node.child(i)!, depth + 1, currentClass);
17581776
}
17591777
}
1760-
walk(rootNode, 0);
1778+
walk(rootNode, 0, null);
17611779
}
17621780

17631781
/** Extract type info from a variable_declarator: type annotation, constructor, or factory. */
@@ -1957,12 +1975,17 @@ function handleParamTypeMap(node: TreeSitterNode, typeMap: Map<string, TypeMapEn
19571975
* Phase 8.3d: seed the pts map from object property writes.
19581976
*
19591977
* `handlers.auth = authMiddleware` → typeMap.set('handlers.auth', { type: 'authMiddleware', confidence: 0.85 })
1960-
* `this.logger = new Logger(...)` → typeMap.set('this.logger', { type: 'Logger', confidence: 1.0 })
1978+
* `this.logger = new Logger(...)` → typeMap.set('UserService.logger', { type: 'Logger', confidence: 1.0 })
1979+
* (keyed as ClassName.prop when currentClass is known, to avoid collisions across classes)
19611980
*
19621981
* Only simple `obj.prop = identifier` and `this.prop = new Ctor()` writes are tracked
19631982
* (not chained `a.b.c = x`). BUILTIN_GLOBALS are skipped (e.g. `console.log = fn`).
19641983
*/
1965-
function handlePropWriteTypeMap(node: TreeSitterNode, typeMap: Map<string, TypeMapEntry>): void {
1984+
function handlePropWriteTypeMap(
1985+
node: TreeSitterNode,
1986+
typeMap: Map<string, TypeMapEntry>,
1987+
currentClass: string | null,
1988+
): void {
19661989
const lhsN = node.childForFieldName('left');
19671990
const rhsN = node.childForFieldName('right');
19681991
if (!lhsN || !rhsN) return;
@@ -1975,10 +1998,15 @@ function handlePropWriteTypeMap(node: TreeSitterNode, typeMap: Map<string, TypeM
19751998
// computed subscript expressions — consistent with the adjacent fnRefBindings block.
19761999
if (prop.type !== 'property_identifier' && prop.type !== 'identifier') return;
19772000

1978-
// this.prop = new ClassName(...) — constructor-assigned property type
2001+
// this.prop = new ClassName(...) — constructor-assigned property type.
2002+
// Key as ClassName.prop (class-scoped) so two classes with identically-named
2003+
// properties don't overwrite each other's typeMap entry.
19792004
if (obj.type === 'this' && rhsN.type === 'new_expression') {
19802005
const ctorType = extractNewExprTypeName(rhsN);
1981-
if (ctorType) setTypeMapEntry(typeMap, `this.${prop.text}`, ctorType, 1.0);
2006+
if (ctorType) {
2007+
const key = currentClass ? `${currentClass}.${prop.text}` : `this.${prop.text}`;
2008+
setTypeMapEntry(typeMap, key, ctorType, 1.0);
2009+
}
19822010
return;
19832011
}
19842012

@@ -2255,6 +2283,29 @@ function extractSpreadForOfWalk(
22552283
funcStack.push(nameNode.text);
22562284
pushedFunc = true;
22572285
}
2286+
} else if (node.type === 'assignment_expression') {
2287+
// `obj.method = function() { ... }` — func-prop assignment.
2288+
// Mirror handleFuncPropAssignment's logic so for-of loops inside the
2289+
// body get the correct enclosingFunc (e.g. 'obj.method') instead of
2290+
// '<module>' or the wrong outer function name.
2291+
const lhs = node.childForFieldName('left');
2292+
const rhs = node.childForFieldName('right');
2293+
if (
2294+
lhs?.type === 'member_expression' &&
2295+
(rhs?.type === 'function_expression' || rhs?.type === 'arrow_function')
2296+
) {
2297+
const obj = lhs.childForFieldName('object');
2298+
const prop = lhs.childForFieldName('property');
2299+
if (
2300+
obj?.type === 'identifier' &&
2301+
(prop?.type === 'property_identifier' || prop?.type === 'identifier') &&
2302+
!BUILTIN_GLOBALS.has(obj.text) &&
2303+
prop.text !== 'prototype'
2304+
) {
2305+
funcStack.push(`${obj.text}.${prop.text}`);
2306+
pushedFunc = true;
2307+
}
2308+
}
22582309
}
22592310

22602311
if (node.type === 'call_expression') {

tests/benchmarks/resolution/fixtures/javascript/driver.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import { directInstantiation, main } from './index.js';
1414
import { Logger } from './logger.js';
15+
import { ClassA, ClassB } from './multi-class.js';
1516
import { buildService } from './service.js';
1617
import { normalize, validate } from './validators.js';
1718

@@ -39,6 +40,10 @@ try {
3940
svc.createUser({ name: 'Direct' });
4041
svc.deleteUser(99);
4142

43+
// Multi-class fixture — exercises class-scoped this.prop typeMap (issue #1323)
44+
new ClassA().runA();
45+
new ClassB().runB();
46+
4247
globalThis.__tracer.popCall();
4348
} catch {
4449
// Swallow errors — we only care about call edges

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,34 @@
247247
"kind": "calls",
248248
"mode": "defineProperty-accessor",
249249
"notes": "this.accessMethod() inside accessorGetter — this === accessorTarget (get accessor via Object.defineProperty); accessorTarget.accessMethod is the qualified node name for the arrow function property of accessorTarget"
250+
},
251+
{
252+
"source": { "name": "ClassA.constructor", "file": "multi-class.js" },
253+
"target": { "name": "ServiceA", "file": "multi-class.js" },
254+
"kind": "calls",
255+
"mode": "constructor",
256+
"notes": "new ServiceA() — constructor call in ClassA.constructor"
257+
},
258+
{
259+
"source": { "name": "ClassB.constructor", "file": "multi-class.js" },
260+
"target": { "name": "ServiceB", "file": "multi-class.js" },
261+
"kind": "calls",
262+
"mode": "constructor",
263+
"notes": "new ServiceB() — constructor call in ClassB.constructor"
264+
},
265+
{
266+
"source": { "name": "ClassA.runA", "file": "multi-class.js" },
267+
"target": { "name": "ServiceA.doA", "file": "multi-class.js" },
268+
"kind": "calls",
269+
"mode": "receiver-typed",
270+
"notes": "this.service.doA() — receiver-typed via ClassA.service = new ServiceA() (class-scoped typeMap key prevents collision with ClassB.service)"
271+
},
272+
{
273+
"source": { "name": "ClassB.runB", "file": "multi-class.js" },
274+
"target": { "name": "ServiceB.doB", "file": "multi-class.js" },
275+
"kind": "calls",
276+
"mode": "receiver-typed",
277+
"notes": "this.service.doB() — receiver-typed via ClassB.service = new ServiceB() (class-scoped typeMap key prevents collision with ClassA.service)"
250278
}
251279
]
252280
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Fixture: two classes in the same file that both use `this.service`,
3+
* but assign different types. Before the fix, the second class's
4+
* typeMap entry overwrote the first, causing one class to resolve
5+
* `this.service.method()` against the wrong type (false edge).
6+
*/
7+
8+
export class ServiceA {
9+
doA() {}
10+
}
11+
12+
export class ServiceB {
13+
doB() {}
14+
}
15+
16+
export class ClassA {
17+
constructor() {
18+
this.service = new ServiceA();
19+
}
20+
21+
runA() {
22+
this.service.doA();
23+
}
24+
}
25+
26+
export class ClassB {
27+
constructor() {
28+
this.service = new ServiceB();
29+
}
30+
31+
runB() {
32+
this.service.doB();
33+
}
34+
}

0 commit comments

Comments
 (0)