Skip to content

Commit 16a1182

Browse files
authored
test(bench): add JS/TS super.method() class-inheritance fixtures (#1325)
* feat(resolver): phase 8.4 — barrel file re-export chain resolution for call edges The JS/WASM call-edge path (buildImportedNamesMap) was mapping imported symbol names to the barrel file itself rather than the actual definition file. For example, `Button` imported from `components/index.ts` (a barrel) was mapped to `components/index.ts` instead of `components/Button.ts`, so resolveCallTargets could not find the node and the call edge was dropped. The native engine already traced through barrels correctly in buildImportedNamesForNative. This PR mirrors that behavior in the WASM/JS fallback path and caches results to avoid redundant DFS walks across files. Changes: - PipelineContext: add barrelExportCache (build-scoped Map keyed by "barrelPath|symbolName") to avoid repeated traversal of the reexportMap - resolve-imports: export resolveBarrelExportCached, a cache-aware wrapper around resolveBarrelExport; reset cache at the start of each build run - build-edges/buildImportedNamesMap: add barrel tracing via traceBarrel helper — matches the logic already in buildImportedNamesForNative - build-edges: replace all resolveBarrelExport calls with the cached variant (emitTypeOnlySymbolEdges, buildBarrelEdges, buildImportedNamesForNative, makeContextLookup) for consistency and performance Also opens #1297 for the pre-existing WASM-only failure where barrel-through import edges are not emitted on full builds. * fix(resolver): remove unreachable ?? null after confirmed .has() check in resolveBarrelExportCached * test(wasm): document #1297 guard in chained-barrel regression test The "emits barrel-through edges on full build" assertion in this test also guards #1297 (barrel-through import edges missing on WASM full builds). The fix was delivered by the phase 8.4 changes to buildBarrelEdges — switching to resolveBarrelExportCached which initialises ctx.barrelExportCache before the edge-building pass. Closes #1297 * feat(resolver): phase 8.5 — enhanced dynamic dispatch resolution (CHA + RTA) Introduces Class Hierarchy Analysis (CHA) and Rapid Type Analysis (RTA) to improve call-graph coverage for OOP codebases. CHA: when a call targets an interface or abstract method (e.g. worker.doWork() where worker: IWorker), emit additional call edges to all known concrete implementations reachable via the class/interface hierarchy. RTA filter: CHA targets are narrowed to types actually instantiated in the program via new X() — prevents dead/test-only implementations from inflating the dispatch fan-out. this/self/super dispatch: inside a method body, this.method() now resolves through the class's own method table and parent hierarchy via qualified-name lookup (e.g. ClassName.method), rather than relying solely on global name matching which may miss inherited or ambiguous methods. Implementation: - src/domain/graph/builder/cha.ts (new): ChaContext, buildChaContext, resolveThisDispatch, resolveChaTargets - ExtractorOutput.newExpressions: dedicated RTA instantiation list extracted from all new X() expressions in JS/TS files (not just assigned ones) - WASM path: CHA dispatch runs inline in buildFileCallEdges - Native path: DB-based CHA expansion post-pass in tryNativeOrchestrator (interface dispatch only; this/super dispatch is a known gap for native) - build-edges.ts: buildChaPostPass wired for native FFI path (fallback mode) Test: tests/integration/phase-8.5-cha-dispatch.test.ts covers CHA dispatch, RTA filter, this-dispatch (wasm only), and super-dispatch (wasm only) across the cha-dispatch fixture with IWorker/ConcreteWorker/MockWorker/GhostWorker. * fix(resolver): document Phase 8.2 side-effect and strengthen barrel test assertion (#1302) - Add caller_file check to the barrel call resolution test assertion to future-proof it against fixture growth (greptile review) - Add comment in propagateReturnTypesAcrossFiles documenting the Phase 8.4 barrel-tracing side-effect on cross-file return-type propagation * fix(cha): propagate newExpressions through WASM worker serialization for C# RTA (#1302) C# extractor never populated newExpressions, so WASM CHA had no RTA seed for C# instantiated types. Additionally, newExpressions was stripped during worker thread serialization (SerializedExtractorOutput lacked the field, and neither serializeExtractorOutput nor deserializeResult handled it). - csharp.ts: populate newExpressions in extractCSharpSymbols and push type names in handleCsObjectCreation - wasm-worker-protocol.ts: add newExpressions field to SerializedExtractorOutput - wasm-worker-entry.ts: include newExpressions in serializeExtractorOutput - wasm-worker-pool.ts: restore newExpressions in deserializeResult * fix(parity): re-classify roles after CHA post-pass for native engine (#1302) The Rust orchestrator classifies roles as part of its internal pipeline, before the JS runPostNativeCha post-pass adds CHA call edges. Implementor methods (e.g. UserRepository.Save) had no incoming edges at classification time and were tagged dead-ffi, diverging from the WASM engine which classifies roles after all edges (including CHA) are inserted. runPostNativeCha now returns the Set of target node IDs for newly inserted edges. tryNativeOrchestrator uses this set to query the affected files and re-runs classifyNodeRoles (incremental) on those files only, bringing native and WASM role results into alignment. * fix(cha): add debug log + transaction wrapper to runPostNativeCha (#1302) * fix(bench): add CHA-expanded C# edges to expected-edges manifest (#1302) Phase 8.5 CHA+RTA correctly expands IRepository interface dispatch to UserRepository concrete implementations (UserRepository.FindById, UserRepository.Save, UserRepository.Delete). These are genuine runtime dispatch edges — the manifest was incomplete, not the resolver wrong. Adding them restores C# precision to 100% and improves recall 52.6%→60.9%. * fix(cha): extend RTA query to non-class nodes, skip filter without evidence (#1302) When the native engine records constructor calls against `constructor`-kind or `function`-kind nodes instead of `class`-kind nodes, the `instantiated` set was always empty and runPostNativeCha returned early — silently skipping all CHA interface dispatch. Fix adds a fallback query for those node kinds, and when no constructor-call evidence exists at all, proceeds without the RTA filter so interface dispatch still expands correctly. * docs(bench): jelly vs codegraph call resolution comparison on JS/TS fixtures (#1301) Run Jelly 0.13.0 on the JavaScript and TypeScript hand-annotated fixture projects and compare its precision/recall against codegraph's on the same expected-edges.json ground truth. Key findings: - JS: codegraph 100%/83% P/R vs Jelly 94%/94% — Jelly resolves receiver-typed calls through constructor-assigned properties (this.prop = new Foo()); codegraph does not yet - TS: codegraph 100%/72% P/R vs Jelly 100%/56% — codegraph leads on callbacks and barrel re-exports; Jelly leads on interface dispatch (5/5 vs 0/5) - TS interface-dispatched gap is the highest-priority recall improvement (codegraph's CHA post-pass not yet wired for TypeScript) - Java: no suitable Java call graph tool runs on raw .java source without compilation; documents javacg-static as the recommended tool once a build step is added Adds: - docs/benchmarks/RESOLUTION-COMPARISON.md — full comparison with per-mode breakdown - scripts/compare-jelly.mjs — runs Jelly on a fixture dir and computes P/R vs ground truth * Revert "research(bench): Jelly vs codegraph call resolution comparison on JS/TS fixtures (#1301)" This reverts commit cbaa4a2. * feat(stats): by_technique breakdown in codegraph stats (Phase 8.6 follow-up) (#1303) * feat(resolver): phase 8.3d — object property write tracking in points-to analysis Walk assignment_expression nodes in extractTypeMapWalk (WASM) and match_js_type_map (native). When LHS is a simple member_expression (obj.prop) and RHS is an identifier, seed typeMap['obj.prop'] = { type: fn, confidence: 0.85 }. This covers patterns like: handlers.auth = authMiddleware; router.use(handlers.auth); // -> edge: caller -> authMiddleware Extend resolveByMethodOrGlobal (TS) and resolve_call_targets (Rust) with a composite key lookup (step 4.5): when a call has receiver + name, check typeMap['receiver.name'] for a direct pts target before falling through to the no-match return. Skips BUILTIN_GLOBALS / builtin objects (console, Math, etc.) and chained writes (a.b.c = x). Closes #1292 * fix(extractor): add process/window/document/globalThis to BUILTIN_GLOBALS (#1295) Adds the four names present in Rust's is_js_builtin_global but absent from the TypeScript BUILTIN_GLOBALS set, restoring dual-engine parity for property-write pts tracking. Also adds prop.type guard in handlePropWriteTypeMap consistent with the adjacent fnRefBindings block. * test(extractor): fix misleading test name and add higher-confidence promotion test (#1295) Renames 'higher-confidence entry wins' to accurately describe equal-confidence first-write behavior. Removes stale inner comment. Adds explicit test for strict-higher-confidence promotion via setTypeMapEntry. Extends BUILTIN_GLOBALS test to cover process/window/document/globalThis. * fix(resolver): fall through on empty pts results in step 4.5 TS path (#1295) * fix(extractor): sync is_js_builtin_global with full TS BUILTIN_GLOBALS set (#1295) * feat(stats): by_technique breakdown in codegraph stats (Phase 8.6 follow-up) Closes #1300 - DB migration v17: adds `technique TEXT` column + index to `edges` table - Tags call edges at insertion: `ts-native` for direct/native-path calls, `points-to` for pts post-pass and pts-fallback edges; non-call edges get NULL - Upgrades pts edges to `ts-native` in-place when a direct call supersedes them - Native bulkInsertEdges path: technique is backfilled via a post-insert SQL UPDATE (pts edges targeted first; remaining NULL calls tagged ts-native as baseline) - Preserves technique through incremental-build reverse-dep edge reconnection - `computeQualityMetrics` / `buildStatsFromNative` expose `byTechnique` in `callerCoverage`; the JSON output and `printQuality` display it when present docs check acknowledged Impact: 20 functions changed, 23 affected * fix(stats): scope technique UPDATE to batch source IDs; honour testFilter (#1303) - applyEdgeTechniquesAfterNativeInsert: scope the catch-all 'ts-native' UPDATE to source_ids in the current batch — prevents mis-tagging pre-migration NULL-technique edges from unchanged files on the first incremental build after a v16->v17 migration - countCallEdgesByTechnique: accept testFilter (JOIN on source node) so byTechnique counts are consistent with --no-tests - buildStatsFromNative: thread noTests into countCallEdgesByTechnique * fix(builder): backfill technique column after native orchestrator (#1303) The Rust orchestrator writes edges without the technique column. Add a post-orchestrator step that tags all new 'calls' edges as 'ts-native': full builds use a global UPDATE, incremental builds scope to changed-file source nodes to avoid overwriting existing technique values. * test(pts): assert technique column on property-write pts edges (#1303) - readCallEdges: SELECT e.technique in addition to existing columns - readEngine: read build_meta engine to detect native vs WASM path - Assertions check technique is 'ts-native' (native orchestrator) or 'points-to' (JS path), confirming the technique backfill is working * fix(stats): guard quiet incremental in backfillEdgeTechniques (#1303) Quiet incremental builds (no files changed) insert no new edges — running the global UPDATE would mis-tag pre-migration NULL-technique edges from unchanged files as 'ts-native'. Return early when changedFiles is defined but empty. * fix(cha): add missing technique field to CHA dispatch EdgeRowTuple pushes The merge commit ad3fb07 added the 6th technique field to EdgeRowTuple but missed updating two CHA dispatch pushes in buildChaPostPass (line 691) and buildFileCallEdges (line 1024). Both are now tagged with 'cha' to identify the dispatch mechanism consistently with other technique-labeled edges. * fix(cha): correct inaccurate cycle guard comment in resolveThisDispatch The comment said 'cap at 20' but no numeric limit exists — only the visited set guards against cycles. Replace with accurate description. * fix(cha): scope existingPairs dedup and compute file-pair-aware confidence in runPostNativeCha Two improvements to the native orchestrator CHA post-pass: 1. existingPairs full-scan replaced with scoped query: instead of loading every calls edge in the DB, seed the seen-pairs Set from only the source_ids present in callToMethods. This avoids O(all-edges) memory usage on large codebases. 2. Hardcoded confidence 0.8 replaced with file-pair-aware formula matching the WASM path: computeConfidence(callerFile, targetFile, null) - 0.1. The callToMethods query now joins src nodes to get caller_file; the findMethodStmt returns method_file so both sides are available. * feat(cha): transitive multi-level class hierarchy expansion in CHA dispatch Two separate gaps prevented abstract-class hierarchies from working in CHA: 1. `resolveChaTargets` (cha.ts) and `runPostNativeCha` (native-orchestrator.ts) both did a single-level `implementors.get(typeName)` lookup. For a hierarchy like IJob → AbstractJob (non-instantiated) → PrintJob/ScanJob the expansion returned nothing because AbstractJob was skipped by the RTA filter and PrintJob/ScanJob were never visited. Fixed by replacing the single-level lookup with a BFS traversal that traverses all nodes regardless of RTA admission, emitting edges only for instantiated leaf types. 2. `abstract class X implements Y` uses the node type `abstract_class_declaration` in tree-sitter-typescript — a distinct node from `class_declaration`. Neither the WASM extractor (JS walk-path, query patterns) nor the Rust extractor handled this node type, so `implements`/`extends` relations on abstract classes were silently dropped and the implementors map was empty for any interface whose only direct implementor was an abstract class. Fixed in: - src/extractors/javascript.ts (walk switch, kindMaps, JS_CLASS_TYPES, extractReturnTypeMapWalk) - src/domain/parser.ts + wasm-worker-entry.ts (added query pattern) - crates/codegraph-core/src/extractors/javascript.rs (match_js_node, handle_export_declaration, JS_CLASS_KINDS) Closes #1311 * fix(cha): drop zero-confidence edges and scope seenByPair to calls in CHA post-passes (#1302) Two parity gaps between the native-orchestrator and WASM/FFI CHA paths: 1. runPostNativeCha used Math.max(0, conf - 0.1) which still pushed zero-confidence edges. buildFileCallEdges and buildChaPostPass both guard with `if (conf > 0)` to skip them entirely. Switch to the same guard to match. 2. buildChaPostPass seeded seenByPair from all allEdgeRows including import/extends/implements edges. If a file-level call shares a (source_id, target_id) pair with a pre-existing import edge the CHA call edge would be silently suppressed. Restrict seeding to rows where row[2] === 'calls', matching buildParamFlowPtsPostPass intent. * fix(cha): chunk IN-clause params in applyEdgeTechniques and backfillEdgeTechniques (#1302) Both applyEdgeTechniquesAfterNativeInsert (build-edges.ts) and backfillEdgeTechniquesAfterNativeOrchestrator (native-orchestrator.ts) built unbounded IN-clause placeholders from sourceIds / changedFiles and spread them as variadic SQLite args. On codebases with > 999 distinct callers or changed files, SQLite threw "too many SQL variables". Fix: iterate in CHUNK_SIZE=500 batches (same pattern used elsewhere) and wrap in a transaction for atomicity. * test(cha): skip transitive CHA native tests until Rust binary updated (#1302) The transitive multi-level CHA tests (IJob → AbstractJob → PrintJob/ScanJob) rely on the Rust extractor emitting implements/extends edges for abstract_class_declaration nodes. The pre-compiled native binary (v3.11.2) predates that fix, so mark those tests with skipIf(engine === 'native') until the binary is rebuilt — matching the same pattern used for this-dispatch and super-dispatch. Also add 429 rate-limit guard to embedding-regression.test.ts: catch HuggingFace 429 errors in beforeAll and skip individual tests gracefully rather than failing the whole suite when the model download is throttled. * fix(cha): tag runPostNativeCha edges with technique='cha' (#1302) The newEdges tuple in runPostNativeCha was typed as 5 elements, so batchInsertEdges wrote technique=NULL. backfillEdgeTechniquesAfterNativeOrchestrator then overwrote those NULLs with 'ts-native', mislabelling all CHA-expanded edges from the native orchestrator path. Fix extends the tuple to 6 elements with 'cha' at index 5, matching buildChaPostPass and buildFileCallEdges. * fix(cha): guard CHA dedup against ptsEdgeRows to prevent duplicate edges (#1302) The CHA expansion loop in buildFileCallEdges checked seenCallEdges but not ptsEdgeRows, so a pts-alias edge and a CHA-expanded edge for the same (caller.id, target.id) pair could both be written to allEdgeRows. batchInsertEdges uses plain INSERT INTO (no OR IGNORE), so both rows would persist and inflate the target's fan-in count, skewing role classification and dead-code detection. Add !ptsEdgeRows.has(edgeKey) to the guard, matching the pattern used in the pts expansion loop. * test(bench): add JS/TS super.method() class-inheritance fixtures (#1315) Add resolution benchmark fixtures that verify super.method() dispatch through the class hierarchy in both JavaScript and TypeScript. JavaScript fixture covers: - Instance super.method() (Dog.speak → Animal.speak, 2-level) - Multi-level super.method() (Puppy.speak → Dog.speak, stops at nearest parent) - Static super.method() (DoubleCounter.count → Counter.count) TypeScript fixture covers: - Multi-level super.method() chain (PrefixLogger.log → TimestampLogger.log → Logger.log) All edges resolve correctly via the CHA resolveThisDispatch path (Phase 8.5) and are annotated with mode class-inheritance. * fix(cha): export CHA_DISPATCH_PENALTY and consume it in native-orchestrator (#1325) The constant was defined but not exported, causing native-orchestrator.ts to duplicate the magic number 0.1 inline. If tuned in one place the other would silently diverge, producing different confidence values between the WASM and native paths for the same CHA edge. * test: replace skipIf/silent-pass patterns with todo and ctx.skip (#1325) - Convert it.skipIf(engine === 'native') for this/super/transitive CHA gaps to it.todo() with issue #1326 reference so the native accuracy gap is tracked as pending work, not silently hidden - Replace if (rateLimited) return in embedding-regression tests with ctx.skip() so rate-limited runs show as skipped in Vitest output instead of green passes * feat(resolver): resolve prototype-based method calls (#1317) Three patterns now resolved for pre-ES6 OOP: - `Foo.prototype.bar = function(){}` → emits Foo.bar as a method definition - `Foo.prototype.bar = identifier` → seeds typeMap['Foo.bar'] for alias dispatch - `Foo.prototype = { bar: fn }` → emits definitions per property Extractor: new extractPrototypeMethodsWalk pass, called from both the query path and manual-walk path in the JS extractor. Resolver: resolveByMethodOrGlobal gains two new fallbacks: - Inline new-expression receivers: extracts the class name from `new Foo()` when typeMap has no entry for the raw receiver text - Prototype alias lookup: after a symbol-DB miss for TypeName.method, checks typeMap['TypeName.method'] for identifier-aliased methods Adds prototypes.js and prototypes2.js benchmark fixtures; JavaScript benchmark stays at 100% precision / 100% recall. Filed #1327 for native Rust engine parity. Closes #1317 * fix(test): retry rmSync with backoff on Windows EBUSY in embedding-regression afterAll (#1325)
1 parent 34988f8 commit 16a1182

12 files changed

Lines changed: 411 additions & 65 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: 1 addition & 1 deletion
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

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
readFileSafe,
5353
} from '../helpers.js';
5454
import { NativeDbProxy } from '../native-db-proxy.js';
55+
import { CHA_DISPATCH_PENALTY } from './build-edges.js';
5556
import { closeNativeDb } from './native-db-lifecycle.js';
5657

5758
// ── Native orchestrator types ──────────────────────────────────────────
@@ -467,7 +468,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
467468

468469
// Find existing call edges targeting qualified methods (e.g., 'IWorker.doWork').
469470
// Include the caller node's file so confidence can be computed file-pair-aware,
470-
// matching the WASM path's computeConfidence(callerFile, targetFile, null) - 0.1 formula.
471+
// matching the WASM path's computeConfidence(callerFile, targetFile, null) - CHA_DISPATCH_PENALTY formula.
471472
const callToMethods = db
472473
.prepare(`
473474
SELECT e.source_id, tgt.name AS method_name, src.file AS caller_file
@@ -534,10 +535,11 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
534535
const key = `${source_id}|${methodNode.id}`;
535536
if (seen.has(key)) continue;
536537
seen.add(key);
537-
// Compute confidence file-pair-aware (mirrors WASM path: computeConfidence - 0.1 penalty)
538+
// Compute confidence file-pair-aware (mirrors WASM path: computeConfidence - CHA_DISPATCH_PENALTY)
538539
// Skip zero-confidence edges to match buildFileCallEdges / buildChaPostPass behaviour.
539540
const conf =
540-
computeConfidence(caller_file ?? '', methodNode.method_file ?? '', null) - 0.1;
541+
computeConfidence(caller_file ?? '', methodNode.method_file ?? '', null) -
542+
CHA_DISPATCH_PENALTY;
541543
if (conf <= 0) continue;
542544
newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha']);
543545
newTargetIds.add(methodNode.id);

src/extractors/javascript.ts

Lines changed: 161 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,9 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
345345
// Extract typeMap with intra-file return-type propagation
346346
extractTypeMapWalk(tree.rootNode, typeMap, returnTypeMap, callAssignments, fnRefBindings);
347347

348+
// Prototype-based method definitions: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }`
349+
extractPrototypeMethodsWalk(tree.rootNode, definitions, typeMap);
350+
348351
// Phase 8.3c: Extract call-site argument bindings for parameter-flow pts analysis
349352
extractParamBindingsWalk(tree.rootNode, paramBindings);
350353

@@ -476,7 +479,7 @@ function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definiti
476479
if (declarator?.type !== 'variable_declarator') continue;
477480
const nameN = declarator.childForFieldName('name');
478481
const valueN = declarator.childForFieldName('value');
479-
if (!nameN || nameN.type !== 'identifier' || !valueN) continue;
482+
if (nameN?.type !== 'identifier' || !valueN) continue;
480483
// Skip functions — already captured by query patterns
481484
const valType = valueN.type;
482485
if (valType === 'arrow_function' || valType === 'function_expression' || valType === 'function')
@@ -612,6 +615,8 @@ function extractSymbolsWalk(tree: TreeSitterTree): ExtractorOutput {
612615
ctx.callAssignments,
613616
ctx.fnRefBindings,
614617
);
618+
// Prototype-based method definitions: `Foo.prototype.bar = fn` and `Foo.prototype = { bar: fn }`
619+
extractPrototypeMethodsWalk(tree.rootNode, ctx.definitions, ctx.typeMap!);
615620
// Phase 8.3c: Extract call-site argument bindings for parameter-flow pts analysis
616621
extractParamBindingsWalk(tree.rootNode, ctx.paramBindings!);
617622
// Phase 8.5: collect all `new X()` constructor names for RTA instantiation tracking
@@ -1162,7 +1167,7 @@ function extractSimpleTypeName(typeAnnotationNode: TreeSitterNode): string | nul
11621167
}
11631168

11641169
function extractNewExprTypeName(newExprNode: TreeSitterNode): string | null {
1165-
if (!newExprNode || newExprNode.type !== 'new_expression') return null;
1170+
if (newExprNode?.type !== 'new_expression') return null;
11661171
const ctor = newExprNode.childForFieldName('constructor') || newExprNode.child(1);
11671172
if (!ctor) return null;
11681173
if (ctor.type === 'identifier') return ctor.text;
@@ -1275,7 +1280,7 @@ function storeReturnType(
12751280
function findReturnNewExprType(bodyNode: TreeSitterNode): string | null {
12761281
for (let i = 0; i < bodyNode.childCount; i++) {
12771282
const child = bodyNode.child(i);
1278-
if (!child || child.type !== 'return_statement') continue;
1283+
if (child?.type !== 'return_statement') continue;
12791284
for (let j = 0; j < child.childCount; j++) {
12801285
const expr = child.child(j);
12811286
if (expr?.type === 'new_expression') return extractNewExprTypeName(expr);
@@ -1442,7 +1447,7 @@ function handleVarDeclaratorTypeMap(
14421447
fnRefBindings?: FnRefBinding[],
14431448
): void {
14441449
const nameN = node.childForFieldName('name');
1445-
if (!nameN || nameN.type !== 'identifier') return;
1450+
if (nameN?.type !== 'identifier') return;
14461451

14471452
const typeAnno = findChild(node, 'type_annotation');
14481453
const valueN = node.childForFieldName('value');
@@ -1531,7 +1536,7 @@ function handleVarDeclaratorTypeMap(
15311536
function handleParamTypeMap(node: TreeSitterNode, typeMap: Map<string, TypeMapEntry>): void {
15321537
const nameNode =
15331538
node.childForFieldName('pattern') || node.childForFieldName('left') || node.child(0);
1534-
if (!nameNode || nameNode.type !== 'identifier') return;
1539+
if (nameNode?.type !== 'identifier') return;
15351540
const typeAnno = findChild(node, 'type_annotation');
15361541
if (typeAnno) {
15371542
const typeName = extractSimpleTypeName(typeAnno);
@@ -1924,7 +1929,7 @@ function extractCallbackDefinition(
19241929
fn?: TreeSitterNode | null,
19251930
): Definition | null {
19261931
if (!fn) fn = callNode.childForFieldName('function');
1927-
if (!fn || fn.type !== 'member_expression') return null;
1932+
if (fn?.type !== 'member_expression') return null;
19281933

19291934
const prop = fn.childForFieldName('property');
19301935
if (!prop) return null;
@@ -2035,7 +2040,7 @@ function extractDynamicImportNames(callNode: TreeSitterNode): string[] {
20352040
// Skip await_expression wrapper if present
20362041
if (current && current.type === 'await_expression') current = current.parent;
20372042
// We should now be at a variable_declarator (or not, if standalone import())
2038-
if (!current || current.type !== 'variable_declarator') return [];
2043+
if (current?.type !== 'variable_declarator') return [];
20392044

20402045
const nameNode = current.childForFieldName('name');
20412046
if (!nameNode) return [];
@@ -2078,3 +2083,152 @@ function extractDynamicImportNames(callNode: TreeSitterNode): string[] {
20782083

20792084
return [];
20802085
}
2086+
2087+
// ── Phase 8.X: Prototype-based method extraction ────────────────────────────
2088+
2089+
/**
2090+
* Walk the AST and extract prototype-based method definitions and aliases.
2091+
*
2092+
* Handles three patterns:
2093+
* 1. `Foo.prototype.bar = function(){...}` — emits Foo.bar as method definition
2094+
* 2. `Foo.prototype.bar = identifier` — sets typeMap['Foo.bar'] = { type: identifier }
2095+
* 3. `Foo.prototype = { bar: fn, ... }` — emits defs and typeMap entries per property
2096+
*
2097+
* Emitting definitions under the canonical `ClassName.methodName` name lets the
2098+
* existing typeMap-based call resolver find them when a typed receiver dispatches
2099+
* `instance.method()` (lookup.byName('C.foo') in resolveByMethodOrGlobal).
2100+
*
2101+
* typeMap entries for identifier aliases (`Foo.bar → { type: 'someId' }`) are
2102+
* consumed by the prototype-alias fallback added to resolveByMethodOrGlobal.
2103+
*/
2104+
function extractPrototypeMethodsWalk(
2105+
rootNode: TreeSitterNode,
2106+
definitions: Definition[],
2107+
typeMap: Map<string, TypeMapEntry>,
2108+
): void {
2109+
function walk(node: TreeSitterNode, depth: number): void {
2110+
if (depth >= MAX_WALK_DEPTH) return;
2111+
if (node.type === 'expression_statement') {
2112+
const expr = node.child(0);
2113+
if (expr?.type === 'assignment_expression') {
2114+
const lhs = expr.childForFieldName('left');
2115+
const rhs = expr.childForFieldName('right');
2116+
if (lhs && rhs) handlePrototypeAssignment(lhs, rhs, definitions, typeMap);
2117+
}
2118+
}
2119+
for (let i = 0; i < node.childCount; i++) {
2120+
walk(node.child(i)!, depth + 1);
2121+
}
2122+
}
2123+
walk(rootNode, 0);
2124+
}
2125+
2126+
/**
2127+
* Handle an assignment_expression that may be a prototype assignment.
2128+
*
2129+
* Matches:
2130+
* - `Foo.prototype.bar = rhs` (lhs ends in .prototype.bar)
2131+
* - `Foo.prototype = { ... }` (lhs ends in .prototype, rhs is object literal)
2132+
*/
2133+
function handlePrototypeAssignment(
2134+
lhs: TreeSitterNode,
2135+
rhs: TreeSitterNode,
2136+
definitions: Definition[],
2137+
typeMap: Map<string, TypeMapEntry>,
2138+
): void {
2139+
if (lhs.type !== 'member_expression') return;
2140+
2141+
const lhsObj = lhs.childForFieldName('object');
2142+
const lhsProp = lhs.childForFieldName('property');
2143+
if (!lhsObj || !lhsProp) return;
2144+
2145+
// Pattern 1: `Foo.prototype.bar = rhs`
2146+
// lhs.object is `Foo.prototype` (member_expression), lhs.property is `bar`
2147+
if (
2148+
lhsObj.type === 'member_expression' &&
2149+
(lhsProp.type === 'property_identifier' || lhsProp.type === 'identifier')
2150+
) {
2151+
const protoObj = lhsObj.childForFieldName('object');
2152+
const protoProp = lhsObj.childForFieldName('property');
2153+
if (
2154+
protoObj?.type === 'identifier' &&
2155+
protoProp?.text === 'prototype' &&
2156+
!BUILTIN_GLOBALS.has(protoObj.text)
2157+
) {
2158+
emitPrototypeMethod(protoObj.text, lhsProp.text, rhs, definitions, typeMap);
2159+
}
2160+
return;
2161+
}
2162+
2163+
// Pattern 2: `Foo.prototype = { bar: fn, ... }`
2164+
// lhs.object is `Foo` (identifier), lhs.property is `prototype`
2165+
if (
2166+
lhsObj.type === 'identifier' &&
2167+
lhsProp.text === 'prototype' &&
2168+
!BUILTIN_GLOBALS.has(lhsObj.text) &&
2169+
rhs.type === 'object'
2170+
) {
2171+
extractPrototypeObjectLiteral(lhsObj.text, rhs, definitions, typeMap);
2172+
}
2173+
}
2174+
2175+
/** Emit one prototype method definition or typeMap alias for `ClassName.methodName = rhs`. */
2176+
function emitPrototypeMethod(
2177+
className: string,
2178+
methodName: string,
2179+
rhs: TreeSitterNode,
2180+
definitions: Definition[],
2181+
typeMap: Map<string, TypeMapEntry>,
2182+
): void {
2183+
const fullName = `${className}.${methodName}`;
2184+
if (rhs.type === 'function_expression' || rhs.type === 'arrow_function') {
2185+
definitions.push({
2186+
name: fullName,
2187+
kind: 'method',
2188+
line: nodeStartLine(rhs),
2189+
endLine: nodeEndLine(rhs),
2190+
});
2191+
} else if (rhs.type === 'identifier' && !BUILTIN_GLOBALS.has(rhs.text)) {
2192+
// Prototype alias: `A.prototype.t = f` → typeMap['A.t'] = { type: 'f' }
2193+
// Consumed by the prototype-alias fallback in resolveByMethodOrGlobal.
2194+
setTypeMapEntry(typeMap, fullName, rhs.text, 0.9);
2195+
}
2196+
}
2197+
2198+
/** Iterate over an object literal assigned to `Foo.prototype` and emit defs/aliases. */
2199+
function extractPrototypeObjectLiteral(
2200+
className: string,
2201+
objNode: TreeSitterNode,
2202+
definitions: Definition[],
2203+
typeMap: Map<string, TypeMapEntry>,
2204+
): void {
2205+
for (let i = 0; i < objNode.childCount; i++) {
2206+
const child = objNode.child(i);
2207+
if (!child) continue;
2208+
2209+
if (child.type === 'method_definition') {
2210+
// Shorthand method: `Foo.prototype = { bar() {} }`
2211+
const nameNode = child.childForFieldName('name');
2212+
if (nameNode) {
2213+
definitions.push({
2214+
name: `${className}.${nameNode.text}`,
2215+
kind: 'method',
2216+
line: nodeStartLine(child),
2217+
endLine: nodeEndLine(child),
2218+
});
2219+
}
2220+
continue;
2221+
}
2222+
2223+
if (child.type !== 'pair') continue;
2224+
2225+
const keyNode = child.childForFieldName('key');
2226+
const valueNode = child.childForFieldName('value');
2227+
if (!keyNode || !valueNode) continue;
2228+
2229+
const methodName = keyNode.type === 'string' ? keyNode.text.replace(/['"]/g, '') : keyNode.text;
2230+
if (!methodName) continue;
2231+
2232+
emitPrototypeMethod(className, methodName, valueNode, definitions, typeMap);
2233+
}
2234+
}

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,55 @@
128128
"kind": "calls",
129129
"mode": "constructor",
130130
"notes": "new UserService() — class instantiation tracked as consumption"
131+
},
132+
{
133+
"source": { "name": "Dog.speak", "file": "inheritance.js" },
134+
"target": { "name": "Animal.speak", "file": "inheritance.js" },
135+
"kind": "calls",
136+
"mode": "class-inheritance",
137+
"notes": "super.speak() in Dog.speak resolves to Animal.speak via CHA parents map"
138+
},
139+
{
140+
"source": { "name": "Puppy.speak", "file": "inheritance.js" },
141+
"target": { "name": "Dog.speak", "file": "inheritance.js" },
142+
"kind": "calls",
143+
"mode": "class-inheritance",
144+
"notes": "super.speak() in Puppy.speak resolves to Dog.speak (nearest parent), not Animal.speak"
145+
},
146+
{
147+
"source": { "name": "DoubleCounter.count", "file": "inheritance.js" },
148+
"target": { "name": "Counter.count", "file": "inheritance.js" },
149+
"kind": "calls",
150+
"mode": "class-inheritance",
151+
"notes": "static super.count() in DoubleCounter.count resolves to Counter.count via CHA parents map"
152+
},
153+
{
154+
"source": { "name": "runPrototypes", "file": "prototypes.js" },
155+
"target": { "name": "C", "file": "prototypes.js" },
156+
"kind": "calls",
157+
"mode": "constructor",
158+
"notes": "new C() — constructor call to function-based constructor"
159+
},
160+
{
161+
"source": { "name": "runPrototypes", "file": "prototypes.js" },
162+
"target": { "name": "C.foo", "file": "prototypes.js" },
163+
"kind": "calls",
164+
"mode": "receiver-typed",
165+
"notes": "v.foo() — v typed as C via new C(), C.foo defined via C.prototype = { foo: fn }"
166+
},
167+
{
168+
"source": { "name": "testPrototypeAlias", "file": "prototypes2.js" },
169+
"target": { "name": "A", "file": "prototypes2.js" },
170+
"kind": "calls",
171+
"mode": "constructor",
172+
"notes": "new A() — inline constructor call in new A().t()"
173+
},
174+
{
175+
"source": { "name": "testPrototypeAlias", "file": "prototypes2.js" },
176+
"target": { "name": "f", "file": "prototypes2.js" },
177+
"kind": "calls",
178+
"mode": "receiver-typed",
179+
"notes": "new A().t() — A.prototype.t = f alias; inline new receiver resolved to type A, typeMap['A.t'] = f"
131180
}
132181
]
133182
}

0 commit comments

Comments
 (0)