Skip to content

Commit 0e15e7d

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/resolver-static-confidence-1398-2
2 parents 7ef79a2 + 0754216 commit 0e15e7d

29 files changed

Lines changed: 1452 additions & 152 deletions

.github/workflows/claude.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949

5050
- name: Run Automated AI Review
5151
id: automated-review
52-
uses: anthropics/claude-code-action@64de744025ca9e24df2b88204b4f1e968f39f009
52+
uses: anthropics/claude-code-action@28f83620103c48a57093dcc2837eec89e036bb9f
5353
with:
5454
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
5555
model: claude-sonnet-4-6
@@ -208,7 +208,7 @@ jobs:
208208

209209
- name: Run Interactive AI Assistant
210210
id: interactive-claude
211-
uses: anthropics/claude-code-action@64de744025ca9e24df2b88204b4f1e968f39f009
211+
uses: anthropics/claude-code-action@28f83620103c48a57093dcc2837eec89e036bb9f
212212
with:
213213
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
214214
model: claude-sonnet-4-6

package-lock.json

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,11 @@ export function resolveByMethodOrGlobal(
202202
if (call.receiver && callerName) {
203203
const dotIdx = callerName.lastIndexOf('.');
204204
if (dotIdx > -1) {
205-
const callerClass = callerName.slice(0, dotIdx);
205+
// Extract only the segment immediately before the method name so that
206+
// 'Namespace.ClassName.method' yields 'ClassName', not 'Namespace.ClassName'.
207+
// Symbols are stored under their bare class name, not their qualified path.
208+
const prevDot = callerName.lastIndexOf('.', dotIdx - 1);
209+
const callerClass = callerName.slice(prevDot + 1, dotIdx);
206210
const qualifiedName = `${callerClass}.${call.name}`;
207211
const sameClass = lookup
208212
.byName(qualifiedName)

src/domain/graph/builder/incremental.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,34 @@ function buildCallEdges(
502502
]),
503503
)
504504
: new Map();
505+
506+
// Phase 8.3f: seed typeMap[callee::restName] = { type: argName } from
507+
// objectRestParamBindings × paramBindings, mirroring buildObjectRestParamPostPass.
508+
// Scoped keys prevent same-name rest-param collisions when two functions in
509+
// the same file both use `...rest` (#1358). The unscoped key is also seeded
510+
// when only one callee uses a given rest name, preserving resolution when
511+
// callerName is null (findCaller couldn't identify the enclosing function).
512+
if (symbols.objectRestParamBindings?.length && symbols.paramBindings?.length) {
513+
const restNameCallees = new Map<string, Set<string>>();
514+
for (const orpb of symbols.objectRestParamBindings) {
515+
if (!restNameCallees.has(orpb.restName)) restNameCallees.set(orpb.restName, new Set());
516+
restNameCallees.get(orpb.restName)!.add(orpb.callee);
517+
}
518+
for (const orpb of symbols.objectRestParamBindings) {
519+
for (const pb of symbols.paramBindings) {
520+
if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) {
521+
const scopedKey = `${orpb.callee}::${orpb.restName}`;
522+
if (!typeMap.has(scopedKey)) {
523+
typeMap.set(scopedKey, { type: pb.argName, confidence: 0.65 });
524+
if (restNameCallees.get(orpb.restName)!.size === 1 && !typeMap.has(orpb.restName)) {
525+
typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 });
526+
}
527+
}
528+
}
529+
}
530+
}
531+
}
532+
505533
const seenCallEdges = new Set<string>();
506534
const lookup = makeIncrementalLookup(db, stmts);
507535
let edgesAdded = 0;
@@ -510,13 +538,59 @@ function buildCallEdges(
510538
if (call.receiver && BUILTIN_RECEIVERS.has(call.receiver)) continue;
511539

512540
const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
513-
const { targets, importedFrom } = resolveCallTargets(
541+
const { targets: initialTargets, importedFrom } = resolveCallTargets(
514542
lookup,
515543
call,
516544
relPath,
517545
importedNames,
518546
typeMap,
547+
caller.callerName,
519548
);
549+
let targets = initialTargets;
550+
551+
if (targets.length === 0 && call.receiver === 'this' && caller.callerName != null) {
552+
const dotIdx = caller.callerName.indexOf('.');
553+
if (dotIdx > 0) {
554+
const className = caller.callerName.slice(0, dotIdx);
555+
const qualifiedName = `${className}.${call.name}`;
556+
const qualified = lookup
557+
.byNameAndFile(qualifiedName, relPath)
558+
.filter((n) => n.kind === 'method');
559+
if (qualified.length > 0) {
560+
targets = qualified;
561+
}
562+
}
563+
}
564+
565+
if (
566+
targets.length === 0 &&
567+
call.receiver === 'this' &&
568+
caller.callerName != null &&
569+
symbols.definePropertyReceivers
570+
) {
571+
const receiverVarName = symbols.definePropertyReceivers.get(caller.callerName);
572+
if (receiverVarName) {
573+
const typeEntry = typeMap.get(receiverVarName);
574+
const typeName = typeEntry
575+
? typeof typeEntry === 'string'
576+
? typeEntry
577+
: (typeEntry as { type?: string }).type
578+
: null;
579+
if (typeName) {
580+
const qualifiedName = `${typeName}.${call.name}`;
581+
const qualified = lookup.byNameAndFile(qualifiedName, relPath);
582+
if (qualified.length > 0) {
583+
targets = [...qualified];
584+
}
585+
}
586+
if (targets.length === 0) {
587+
const sameFile = lookup.byNameAndFile(call.name, relPath);
588+
if (sameFile.length > 0) {
589+
targets = [...sameFile];
590+
}
591+
}
592+
}
593+
}
520594

521595
for (const t of targets) {
522596
const edgeKey = `${caller.id}|${t.id}`;

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/domain/parser.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,14 +168,20 @@ const COMMON_QUERY_PATTERNS: string[] = [
168168
'(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node',
169169
];
170170

171-
// JS: class name is (identifier)
172-
const JS_CLASS_PATTERN: string = '(class_declaration name: (identifier) @cls_name) @cls_node';
171+
// JS: class name is (identifier) — declarations and expressions
172+
const JS_CLASS_PATTERNS: string[] = [
173+
'(class_declaration name: (identifier) @cls_name) @cls_node',
174+
// class expressions: `return class Foo extends Bar { ... }` or `const X = class Foo { ... }`
175+
'(class name: (identifier) @cls_name) @cls_node',
176+
];
173177

174178
// TS/TSX: class name is (type_identifier), plus interface and type alias
175179
// abstract_class_declaration is a separate node type in tree-sitter-typescript
176180
const TS_EXTRA_PATTERNS: string[] = [
177181
'(class_declaration name: (type_identifier) @cls_name) @cls_node',
178182
'(abstract_class_declaration name: (type_identifier) @cls_name) @cls_node',
183+
// class expressions: `return class Foo extends Bar { ... }`
184+
'(class name: (type_identifier) @cls_name) @cls_node',
179185
'(interface_declaration name: (type_identifier) @iface_name) @iface_node',
180186
'(type_alias_declaration name: (type_identifier) @type_name) @type_node',
181187
];
@@ -206,7 +212,7 @@ async function doLoadLanguage(entry: LanguageRegistryEntry): Promise<void> {
206212
const isTS = entry.id === 'typescript' || entry.id === 'tsx';
207213
const patterns = isTS
208214
? [...COMMON_QUERY_PATTERNS, ...TS_EXTRA_PATTERNS]
209-
: [...COMMON_QUERY_PATTERNS, JS_CLASS_PATTERN];
215+
: [...COMMON_QUERY_PATTERNS, ...JS_CLASS_PATTERNS];
210216
_queryCache.set(entry.id, new Query(lang, patterns.join('\n')));
211217
}
212218
} catch (e: unknown) {

src/extractors/javascript.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,8 @@ function walkJavaScriptNode(node: TreeSitterNode, ctx: ExtractorOutput): void {
734734
break;
735735
case 'class_declaration':
736736
case 'abstract_class_declaration':
737+
// class expressions: `return class Foo extends Bar { ... }` or `const X = class Foo { ... }`
738+
case 'class':
737739
handleClassDecl(node, ctx);
738740
break;
739741
case 'method_definition':
@@ -874,7 +876,7 @@ function handleStaticBlock(node: TreeSitterNode, definitions: Definition[]): voi
874876
if (!className) return;
875877
definitions.push({
876878
name: `${className}.<static>`,
877-
kind: 'function',
879+
kind: 'method',
878880
line: nodeStartLine(node),
879881
endLine: nodeEndLine(node),
880882
});
@@ -2141,6 +2143,31 @@ function extractParamBindingsWalk(rootNode: TreeSitterNode, paramBindings: Param
21412143
if (ct === ',' || ct === '(' || ct === ')') continue;
21422144
if (ct === 'identifier' && !BUILTIN_GLOBALS.has(child.text)) {
21432145
paramBindings.push({ callee: fn.text, argIndex: argIdx, argName: child.text });
2146+
} else if (ct === 'spread_element') {
2147+
// f(...[a, b]) — inline array literal: expand each element as a direct param binding.
2148+
const inner =
2149+
child.childForFieldName('argument') ?? (child.childCount > 1 ? child.child(1) : null);
2150+
if (inner?.type === 'array') {
2151+
let elemCount = 0;
2152+
for (let j = 0; j < inner.childCount; j++) {
2153+
const elem = inner.child(j);
2154+
if (!elem) continue;
2155+
if (elem.type === ',' || elem.type === '[' || elem.type === ']') continue;
2156+
if (elem.type === 'identifier' && !BUILTIN_GLOBALS.has(elem.text)) {
2157+
paramBindings.push({
2158+
callee: fn.text,
2159+
argIndex: argIdx + elemCount,
2160+
argName: elem.text,
2161+
});
2162+
}
2163+
elemCount++;
2164+
}
2165+
// Advance by the exact number of slots this spread occupies and skip
2166+
// the unconditional argIdx++ below so that zero-element spreads (...[])
2167+
// do not shift subsequent argument indices.
2168+
argIdx += elemCount;
2169+
continue;
2170+
}
21442171
}
21452172
argIdx++;
21462173
}
@@ -2257,6 +2284,29 @@ function extractSpreadForOfWalk(
22572284
funcStack.push(nameNode.text);
22582285
pushedFunc = true;
22592286
}
2287+
} else if (node.type === 'assignment_expression') {
2288+
// `obj.method = function() { ... }` — func-prop assignment.
2289+
// Mirror handleFuncPropAssignment's logic so for-of loops inside the
2290+
// body get the correct enclosingFunc (e.g. 'obj.method') instead of
2291+
// '<module>' or the wrong outer function name.
2292+
const lhs = node.childForFieldName('left');
2293+
const rhs = node.childForFieldName('right');
2294+
if (
2295+
lhs?.type === 'member_expression' &&
2296+
(rhs?.type === 'function_expression' || rhs?.type === 'arrow_function')
2297+
) {
2298+
const obj = lhs.childForFieldName('object');
2299+
const prop = lhs.childForFieldName('property');
2300+
if (
2301+
obj?.type === 'identifier' &&
2302+
(prop?.type === 'property_identifier' || prop?.type === 'identifier') &&
2303+
!BUILTIN_GLOBALS.has(obj.text) &&
2304+
prop.text !== 'prototype'
2305+
) {
2306+
funcStack.push(`${obj.text}.${prop.text}`);
2307+
pushedFunc = true;
2308+
}
2309+
}
22602310
}
22612311

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

0 commit comments

Comments
 (0)