Skip to content

Commit f279398

Browse files
committed
fix: resolve merge conflicts with main (docs check acknowledged)
2 parents 0b4e8e2 + a233110 commit f279398

13 files changed

Lines changed: 561 additions & 50 deletions

File tree

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,40 @@ export function resolveByMethodOrGlobal(
7373
? call.receiver.slice('this.'.length)
7474
: call.receiver;
7575
const typeEntry = typeMap.get(effectiveReceiver) ?? typeMap.get(call.receiver);
76-
const typeName = typeEntry
76+
let typeName = typeEntry
7777
? typeof typeEntry === 'string'
7878
? typeEntry
7979
: (typeEntry as { type?: string }).type
8080
: null;
81+
82+
// Handle inline new-expression receivers: `(new Foo).bar()` or `(new Foo()).bar()`.
83+
// extractReceiverName returns the raw node text for non-identifier nodes, so `(new A).t()`
84+
// produces receiver='(new A)'. Extract the constructor name directly.
85+
if (!typeName && call.receiver) {
86+
const m = /^\(?\s*new\s+([A-Z_$][A-Za-z0-9_$]*)/.exec(call.receiver);
87+
if (m?.[1]) typeName = m[1];
88+
}
89+
8190
if (typeName) {
8291
const typed = lookup.byName(`${typeName}.${call.name}`).filter((n) => n.kind === 'method');
8392
if (typed.length > 0) return typed;
93+
94+
// Prototype alias: `Foo.prototype.bar = identifier` seeds typeMap['Foo.bar'] = { type: identifier }.
95+
// Checked after the symbol-DB lookup so an actual method definition always wins.
96+
const protoEntry = typeMap.get(`${typeName}.${call.name}`);
97+
const protoTarget = protoEntry
98+
? typeof protoEntry === 'string'
99+
? protoEntry
100+
: (protoEntry as { type?: string }).type
101+
: null;
102+
if (protoTarget) {
103+
const resolved = lookup
104+
.byName(protoTarget)
105+
.filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);
106+
if (resolved.length > 0) return resolved;
107+
}
84108
}
109+
85110
// Phase 8.3d: composite pts key — `obj.prop = fn` seeds typeMap['obj.prop'] = { type: 'fn' }.
86111
// When a call site references `obj.prop` as a callback, resolve directly to the target fn.
87112
const compositeEntry = typeMap.get(`${call.receiver}.${call.name}`);

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

Lines changed: 123 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ interface NativeEdge {
7878
}
7979

8080
/** Phase 8.5: confidence penalty applied to CHA-dispatch edges. */
81-
const CHA_DISPATCH_PENALTY = 0.1;
81+
export const CHA_DISPATCH_PENALTY = 0.1;
8282

8383
// ── Node lookup setup ───────────────────────────────────────────────────
8484

@@ -552,6 +552,7 @@ function buildParamFlowPtsPostPass(
552552
ctx: PipelineContext,
553553
getNodeIdStmt: NodeIdStmt,
554554
allEdgeRows: EdgeRowTuple[],
555+
sharedLookup?: CallNodeLookup,
555556
): void {
556557
// Only process files that actually have paramBindings (avoid useless work).
557558
const filesWithParams = [...ctx.fileSymbols].filter(
@@ -567,7 +568,7 @@ function buildParamFlowPtsPostPass(
567568
}
568569

569570
const { barrelOnlyFiles, rootDir } = ctx;
570-
const lookup = makeContextLookup(ctx, getNodeIdStmt);
571+
const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);
571572

572573
for (const [relPath, symbols] of filesWithParams) {
573574
if (barrelOnlyFiles.has(relPath)) continue;
@@ -620,6 +621,94 @@ function buildParamFlowPtsPostPass(
620621
}
621622
}
622623

624+
/**
625+
* bind/alias pts post-pass for the native call-edge path.
626+
*
627+
* The native Rust engine has no knowledge of JS-layer fnRefBindings (e.g.
628+
* `const f = fn.bind(ctx)`), so calls to bind-created aliases are not resolved
629+
* to their original function on the native path. This JS post-pass runs after
630+
* the native edge pass and adds only the fnRefBindings-seeded pts edges that the
631+
* native engine missed.
632+
*
633+
* Uses the same seenByPair dedup guard as buildParamFlowPtsPostPass to avoid
634+
* duplicating edges already emitted by the native engine.
635+
*/
636+
function buildFnRefBindingsPtsPostPass(
637+
ctx: PipelineContext,
638+
getNodeIdStmt: NodeIdStmt,
639+
allEdgeRows: EdgeRowTuple[],
640+
sharedLookup?: CallNodeLookup,
641+
): void {
642+
// Only process files that actually have fnRefBindings.
643+
const filesWithBindings = [...ctx.fileSymbols].filter(
644+
([, symbols]) => symbols.fnRefBindings && symbols.fnRefBindings.length > 0,
645+
);
646+
if (filesWithBindings.length === 0) return;
647+
648+
// Seed seenByPair from the existing rows so we don't duplicate native edges.
649+
const seenByPair = new Set<string>();
650+
for (const [srcId, tgtId] of allEdgeRows) {
651+
seenByPair.add(`${srcId}|${tgtId}`);
652+
}
653+
654+
const { barrelOnlyFiles, rootDir } = ctx;
655+
const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);
656+
657+
for (const [relPath, symbols] of filesWithBindings) {
658+
if (barrelOnlyFiles.has(relPath)) continue;
659+
const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
660+
if (!fileNodeRow) continue;
661+
662+
const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
663+
const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
664+
const ptsMap = buildPointsToMapForFile(symbols, importedNames);
665+
if (!ptsMap) continue;
666+
667+
// Only resolve calls whose name is an lhs in fnRefBindings — the same
668+
// narrowed guard used in buildFileCallEdges case (c).
669+
const fnRefBindingLhs = new Set(symbols.fnRefBindings!.map((b) => b.lhs));
670+
671+
for (const call of symbols.calls) {
672+
if (call.receiver || call.dynamic) continue; // bind aliases are flat-keyed, never dynamic
673+
if (!fnRefBindingLhs.has(call.name)) continue;
674+
if (!ptsMap.has(call.name)) continue;
675+
676+
const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
677+
678+
// Only resolve calls that had no direct targets (same guard as buildFileCallEdges).
679+
const { targets } = resolveCallTargets(
680+
lookup,
681+
call,
682+
relPath,
683+
importedNames,
684+
typeMap as Map<string, unknown>,
685+
);
686+
if (targets.length > 0) continue;
687+
688+
for (const alias of resolveViaPointsTo(call.name, ptsMap)) {
689+
const { targets: aliasTargets, importedFrom: aliasFrom } = resolveCallTargets(
690+
lookup,
691+
{ name: alias },
692+
relPath,
693+
importedNames,
694+
typeMap as Map<string, unknown>,
695+
);
696+
for (const t of aliasTargets) {
697+
const edgeKey = `${caller.id}|${t.id}`;
698+
if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
699+
const conf =
700+
computeConfidence(relPath, t.file, aliasFrom ?? null) - PROPAGATION_HOP_PENALTY;
701+
if (conf > 0) {
702+
seenByPair.add(edgeKey);
703+
allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'points-to']);
704+
}
705+
}
706+
}
707+
}
708+
}
709+
}
710+
}
711+
623712
/**
624713
* Phase 8.5: CHA + RTA post-pass for the native call-edge path.
625714
*
@@ -889,6 +978,12 @@ function buildFileCallEdges(
889978
// no longer tracked here.
890979
const ptsEdgeRows = new Map<string, number>();
891980

981+
// Pre-compute the set of names that appear as lhs in fnRefBindings so that
982+
// case (c) of the pts gate below only fires for names that are genuine
983+
// bind/alias entries, not for every locally-defined function or import that
984+
// buildPointsToMap seeds with a self-pointing entry.
985+
const fnRefBindingLhs = new Set(symbols.fnRefBindings?.map((b) => b.lhs) ?? []);
986+
892987
for (const call of symbols.calls) {
893988
if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue;
894989

@@ -950,26 +1045,39 @@ function buildFileCallEdges(
9501045
}
9511046
}
9521047

953-
// Phase 8.3 / 8.3c: points-to fallback for unresolved calls.
954-
// Fires for two cases:
1048+
// Phase 8.3 / 8.3c / bind: points-to fallback for unresolved calls.
1049+
// Fires for three cases:
9551050
// (a) dynamic=true: alias calls emitted by extractCallbackReferenceCalls.
9561051
// Looks up `call.name` directly (alias entries are flat-keyed).
9571052
// (b) non-dynamic: parameter variable calls (fn() where fn is a param).
9581053
// Looks up the scoped key `callerName::call.name` to avoid spurious
9591054
// edges from same-named parameters across different functions.
1055+
// (c) non-dynamic: module-level alias bindings — `f = fn.bind(ctx)` or
1056+
// `const f = handler` — where pts('f') was seeded by fnRefBindings.
1057+
// Checked against fnRefBindingLhs (the pre-computed set of lhs names from
1058+
// fnRefBindings) rather than the full ptsMap, so case (c) only fires for
1059+
// genuine bind/alias entries and never for self-seeded local definitions.
9601060
// Confidence is penalised by one hop to reflect the extra indirection.
9611061
//
9621062
// Note: pts edges are added to ptsEdgeRows (not seenCallEdges) so that a later
9631063
// direct call to the same target in the same function body can upgrade confidence
9641064
// rather than being silently dropped by the dedup guard.
9651065
const scopedPtsKey = caller.callerName != null ? `${caller.callerName}::${call.name}` : null;
1066+
const flatPtsKey =
1067+
!call.dynamic && fnRefBindingLhs.has(call.name) && ptsMap?.has(call.name) ? call.name : null;
9661068
if (
9671069
targets.length === 0 &&
9681070
!call.receiver &&
9691071
ptsMap &&
970-
(call.dynamic || (scopedPtsKey != null && ptsMap.has(scopedPtsKey)))
1072+
(call.dynamic || (scopedPtsKey != null && ptsMap.has(scopedPtsKey)) || flatPtsKey != null)
9711073
) {
972-
const ptsLookupName = call.dynamic ? call.name : (scopedPtsKey ?? call.name);
1074+
const ptsLookupName = call.dynamic
1075+
? call.name
1076+
: scopedPtsKey != null && ptsMap.has(scopedPtsKey)
1077+
? scopedPtsKey
1078+
: // flatPtsKey != null is guaranteed by the outer if condition: if neither
1079+
// call.dynamic nor scopedPtsKey matched, flatPtsKey != null must be true.
1080+
flatPtsKey!;
9731081
for (const alias of resolveViaPointsTo(ptsLookupName, ptsMap)) {
9741082
// Resolve the concrete alias target. Only `name` is needed here — receiver
9751083
// and line are not relevant for alias resolution (we are looking up the
@@ -1360,12 +1468,20 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
13601468
(ctx.isFullBuild || ctx.fileSymbols.size > ctx.config.build.smallFilesThreshold);
13611469
if (useNativeCallEdges) {
13621470
buildCallEdgesNative(ctx, getNodeIdStmt, allEdgeRows, allNodesBefore, native!);
1471+
// Build the shared lookup once — both pts post-passes use it, avoiding
1472+
// redundant construction of the same context closure.
1473+
const sharedLookup = makeContextLookup(ctx, getNodeIdStmt);
13631474
// Phase 8.3c post-pass: augment native call edges with parameter-flow pts
13641475
// edges. The native Rust engine has no knowledge of paramBindings, so any
13651476
// `fn()` call inside a higher-order function would be missed. This JS pass
13661477
// runs on top of the native edges and adds only the pts-resolved edges that
13671478
// the native engine could not produce.
1368-
buildParamFlowPtsPostPass(ctx, getNodeIdStmt, allEdgeRows);
1479+
buildParamFlowPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1480+
// bind/alias post-pass: augment native call edges with fnRefBindings-seeded
1481+
// pts edges. The native Rust engine has no knowledge of JS fnRefBindings
1482+
// (e.g. `const f = fn.bind(ctx)`), so calls to bind-created aliases are
1483+
// not resolved to their original function on the native path.
1484+
buildFnRefBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
13691485
// Phase 8.5 post-pass: augment native call edges with CHA-resolved dispatch.
13701486
// The native Rust engine has no knowledge of the CHA context, so this/self
13711487
// calls and interface dispatch are not expanded to concrete implementations.

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
readFileSafe,
5656
} from '../helpers.js';
5757
import { NativeDbProxy } from '../native-db-proxy.js';
58+
import { CHA_DISPATCH_PENALTY } from './build-edges.js';
5859
import { closeNativeDb } from './native-db-lifecycle.js';
5960

6061
// ── Native orchestrator types ──────────────────────────────────────────
@@ -469,7 +470,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
469470

470471
// Find existing call edges targeting qualified methods (e.g., 'IWorker.doWork').
471472
// Include the caller node's file so confidence can be computed file-pair-aware,
472-
// matching the WASM path's computeConfidence(callerFile, targetFile, null) - 0.1 formula.
473+
// matching the WASM path's computeConfidence(callerFile, targetFile, null) - CHA_DISPATCH_PENALTY formula.
473474
const callToMethods = db
474475
.prepare(`
475476
SELECT e.source_id, tgt.name AS method_name, src.file AS caller_file
@@ -536,10 +537,11 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
536537
const key = `${source_id}|${methodNode.id}`;
537538
if (seen.has(key)) continue;
538539
seen.add(key);
539-
// Compute confidence file-pair-aware (mirrors WASM path: computeConfidence - 0.1 penalty)
540+
// Compute confidence file-pair-aware (mirrors WASM path: computeConfidence - CHA_DISPATCH_PENALTY)
540541
// Skip zero-confidence edges to match buildFileCallEdges / buildChaPostPass behaviour.
541542
const conf =
542-
computeConfidence(caller_file ?? '', methodNode.method_file ?? '', null) - 0.1;
543+
computeConfidence(caller_file ?? '', methodNode.method_file ?? '', null) -
544+
CHA_DISPATCH_PENALTY;
543545
if (conf <= 0) continue;
544546
newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha']);
545547
newTargetIds.add(methodNode.id);

0 commit comments

Comments
 (0)