Skip to content

Commit 55f9150

Browse files
authored
fix: align Java interface dispatch across wasm/native/hybrid (#1503)
* chore: gitignore napi-generated artifacts in crates/codegraph-core * chore(tests): remove unused biome suppression in visitor.test.ts * fix(titan-run): sync --start-from enum and phase-timestamp list with actual phases * fix(hooks): track Bash file modifications via before/after git status diff Adds snapshot-pre-bash.sh (PreToolUse Bash) + track-bash-writes.sh (PostToolUse Bash): the pre-hook captures git status --porcelain to a per-worktree temp file before each Bash call; the post-hook diffs the before/after state and appends newly modified or created files to .claude/session-edits.log. This closes the gap where files written by sed -i, printf redirects, tee, heredocs, or build tools (Cargo.lock, lockfiles) were never recorded, causing guard-git.sh to emit false-positive BLOCKED errors. Closes #1457 * chore(native): remove dead code (unused var, method, variant, fields) - clojure.rs: annotate lifetime-anchor assignment to silence false-positive - cfg.rs: remove never-called start_line_of method - complexity.rs: remove never-constructed NotHandled variant; convert irrefutable if-let patterns to plain let destructures - dataflow.rs: remove never-read callee fields from CallReturn/Destructured - incremental.rs: remove never-read lang field from CacheEntry cargo check and cargo clippy both clean after these changes. * refactor(native): extract emit_pts_alias_edges params into PtsAliasCtx struct * fix(wasm): sort call targets by confidence before emit to match native engine * fix(bench): add 2 warmup runs and raise INCREMENTAL_RUNS to 5 for incremental tiers * ci(bench): add per-PR perf canary for extractor/graph/native changes Adds .github/workflows/perf-canary.yml — a path-filtered workflow that fires on PRs touching src/extractors/, src/domain/graph/, or crates/** and runs only the incremental-benchmark suite (full build + no-op + 1-file rebuild, both engines). Catches the class of regressions that accumulated invisibly across the Phase 8.x PRs and were only detected at v3.12.0 publish time. The regression guard gains BENCH_CANARY=1 mode: raises thresholds to 50%/100%/150% (standard/noisy/WASM) and skips the build, query, and resolution suites — only incremental checks run. This absorbs shared- runner timing variance while still blocking catastrophic regressions (+98% full build, +1827% 1-file rebuild from v3.12.0). Closes #1433 * fix(perf): plumb symbolsOnly through parseFilesWasmInline to skip analysis visitors * fix(perf): scope runPostNativeCha to changed files on incremental builds On incremental builds, runPostNativeCha previously scanned all call→qualified-method edges in the DB (~12ms flat, O(graph size)), even for 1-file changes where no hierarchy or RTA evidence changed. Add two cheap indexed gate queries. Gate A checks whether any changed file introduced a class/interface/trait/struct/record node (hierarchy may have new implementors reachable from unchanged call sites). Gate B checks whether any changed file added a call edge to a class-kind target (RTA set may have grown, enabling previously filtered expansions in unchanged callers). If neither gate fires, restrict the candidate query to src.file IN changedFiles — safe because the hierarchy and instantiated set are unchanged for all other files. Full builds (isFullBuild=true) and cases where either gate fires retain the existing full-scan behaviour. Mirrors the changed-files scoping pattern of runPostNativeThisDispatch. Closes #1441 * fix(native): add post-pass phase timings to result.phases Times each JS post-pass in tryNativeOrchestrator and exposes the measurements in BuildResult.phases: - gapDetectMs — dropped-language gap detection + backfill - chaMs — CHA expansion (interface dispatch) - thisDispatchMs — this/super dispatch WASM re-parse (was already tracked but now properly named alongside the rest) - reclassifyMs — scoped role re-classification after edge insertion - techniqueBackfillMs — technique-column UPDATE on native-written edges Previously only thisDispatchMs was reported, causing wall-clock vs phaseSum to diverge by 1.1s+ on 1-file rebuilds and making benchmark regressions undiagnosable from committed history. Updates update-incremental-report.ts to render the new phases in a collapsible details block under each engine's 1-file rebuild section. Closes #1434 * fix(perf): correct INLINE_BACKFILL_THRESHOLD docstring; raise threshold for required-tier grammars The docstring claimed pool cost was "amortised over enough parse work" — measurements show IPC overhead scales linearly (~55–64ms/file pool vs ~8–10ms/file inline). The real motivation is crash safety for exotic WASM grammars (#965); JS/TS/TSX (required-tier, used in all this-dispatch backfill calls) have never triggered the V8 fatal crash class and are safe to run inline. Raise threshold 16 → 32 to keep typical this-dispatch batches (≤ 18 files on the codegraph corpus) on the inline fast path. Exotic-language drops are almost always well under 32 files and also benefit from the inline path without meaningful crash risk increase. Closes #1435 * fix(perf): guard post-native passes against unnecessary work on 1-file incremental rebuilds On 1-file native incremental builds, two JS post-passes ran unconditionally even when they had no work to do: - `backfillNativeDroppedFiles`: called whenever changedCount > 0, even when detectDroppedLanguageGap returned an empty gap. Gate now checks gap.missingAbs.length > 0 || gap.staleRel.length > 0 directly, matching backfillNativeDroppedFiles's own internal early-exit guard. - Node/edge COUNT(*) re-count: ran unconditionally after all post-passes even when none of them wrote any edges. COUNT(*) over 50K+ edge tables is non-trivial, especially via the NativeDbProxy napi-rs round-trip. Now gated on postPassWroteData (backfill | CHA edges | this-dispatch edges). Closes #1454 * chore(types): remove dead protoMethodsMs field and stale comment The post-pass it timed (runPostNativePrototypeMethods) was deleted in b5c03a2 when func-prop extraction moved to Rust (#1432). The optional field was never set by any code path that survived the deletion. Also remove the stale reference to "prototype-methods post-pass" from the parseFilesWasmForBackfill docstring — only the this-dispatch post-pass uses symbolsOnly now. Closes #1432 * fix: class-scope field annotation typeMap keys to prevent cross-class collision Field type annotations (`private repo: OrderRepository`) were seeded as bare file-wide typeMap keys, causing `this.repo` inside `UserService` to resolve to `OrderRepository` when both classes had a `repo` field (issue #1458). Both extractors (TS `handleFieldDefTypeMap` and Rust `field_definition` branch) now seed `ClassName.field` keys at confidence 0.9, matching the `CallerClass.X` resolver fallback added in PR #1382. Bare keys are kept at confidence 0.6 as fallbacks for single-class files or class expressions where no enclosing class name is available. Both engines change identically — parity preserved. * fix(bench): update elixir/julia/objc expected-edges to module-qualified names The resolution benchmark uses WASM-built graphs where the Elixir, Julia, and Objective-C extractors emit module-qualified symbol names (Main.run, App.main, UserService.create_user, etc.). The expected-edges manifests were written with bare unqualified names (run, main, create_user), so every correctly-resolved edge appeared as a false positive and every expected edge appeared as a false negative — causing all three languages to show 0% precision even though resolution was working correctly. Root cause: starting in v3.12.0, cross-module call resolution began working for these languages (via the improved receiver-dispatch and same-class fallback in resolveByMethodOrGlobal / build-edges.ts). With 0 edges previously resolved, the name mismatch was invisible; once edges started resolving, the manifests showed 17 FP (elixir), 11 FP (julia), 6 FP (objc) — all correctly resolved edges misidentified as false positives. Fix: - Update all three expected-edges.json manifests to use the module-qualified names matching actual extractor output: elixir: Main.run, UserService.create_user, Validators.validate_user, etc. julia: App.main, Service.create_user, Repository.new_repo, etc. objc: full ObjC selectors (createUserWithId:name:email:, isValidEmail:, etc.) plus add main -> run (plain C call correctly resolved) - Ratchet THRESHOLDS for all three: elixir: precision 0.0 -> 1.0, recall 0.0 -> 0.8 (17/21 resolved) julia: precision 0.0 -> 1.0, recall 0.0 -> 0.7 (11/15 resolved) objc: precision 0.0 -> 1.0, recall 0.0 -> 0.4 (6/13 resolved) Remaining FNs are genuine unresolved edges (same-file bare calls in elixir/julia, receiver-typed message sends in objc) — not regressions. Closes #1447 * fix(wasm): emit receiver edges for declaration-typed locals in C++/CUDA The JS C++ and CUDA extractors had no handler for 'declaration' AST nodes, so typeMap was never seeded for statically-typed locals (e.g. 'UserService svc;'). Without a typeMap entry for 'svc', resolveReceiverEdge had nothing to look up and silently skipped the receiver edge. Add handleCppDeclaration / handleCudaDeclaration to both extractors. They mirror match_c_family_type_map ('declaration' branch) from the native Rust path: extract the type node text and seed typeMap[varName] = { type, confidence: 0.9 } for each identifier or init_declarator child. Primitive types (int, char, bool, …) are skipped to avoid spurious edges. parity-compare.mjs --langs cpp,cuda --hybrid: PARITY OK (wasm = native = hybrid) All 3044 tests pass. * fix(native): resolve Go factory and Python constructor receiver types in Rust solver Go extractor was only seeding typeMap for var_spec and parameter_declaration, missing short_var_declaration. Added infer_short_var_types to handle: - x := Struct{} → conf 1.0 (composite literal) - x := &Struct{} → conf 1.0 (address-of composite) - x := NewFoo() / x := pkg.NewFoo() → conf 0.7 (New* factory prefix) Python extractor was only seeding typeMap for typed_parameter and typed_default_parameter, missing plain assignment. Added infer_py_assignment_type to handle: - order = Order(...) → conf 1.0 (uppercase constructor) - obj = Module.Class(...) → conf 0.7 (uppercase module prefix, non-builtin) Both mirror the existing JS extractors exactly. Parity check for go and python: wasm vs native/hybrid OK. * fix: align enclosing-caller attribution for variable bindings (haskell, zig) Both engines used different rules for attributing calls inside variable bindings: WASM: attributed to the narrowest enclosing span regardless of kind, so local variable declarations inside fn main() shadowed the enclosing function (Zig: calls attributed to repo/svc variables instead of main), and nested let-bindings inside a Haskell do-block shadowed the top-level main binding. Native: loaded allNodes from a query that excluded 'variable' kind, so top-level Haskell bind nodes (main = do …, kind='variable') never matched in defs_with_ids, causing all calls to fall back to the file node. Unified rule implemented in findCaller (TS) and find_enclosing_caller (Rust): - Function/method definitions are preferred over any variable/constant binding as the enclosing caller scope — local var declarations inside a function body never shadow the enclosing function (fixes Zig repo/svc attribution). - When no function/method encloses the call, fall back to the WIDEST (outermost) variable/constant binding — this handles Haskell where main is a top-level bind node with kind 'variable'. Widest span is used so that nested let-bindings do not shadow the outer main binding. - File node remains the absolute last resort. Also adds 'variable' to NODE_KIND_FILTER_SQL (JS) and EDGE_NODE_KIND_FILTER (Rust pipeline.rs) so top-level variable bindings are included in the allNodes set available for caller matching. parity-compare.mjs --langs haskell,zig --hybrid: PARITY OK — 2/2 fixtures. * chore(lint): fix unused import and formatting in cpp/cuda extractors and test Remove unused TypeMapEntry import from cpp.ts and cuda.ts, reformat primitive-type Set literals and test expect() calls to satisfy biome line-length rules. * fix: align Java interface dispatch across wasm/native/hybrid Java was the only fixture where all three build paths (wasm, native, hybrid) disagreed pairwise. Bug 1 — WASM typeMap pollution: `handleJavaLocalVarDecl` used last-wins Map.set(), so the local `InMemoryUserRepository repo` in the static `createDefault()` method silently overrode the constructor parameter `UserRepository repo`. This caused WASM to bypass the interface and resolve directly to the concrete class, producing no interface edge and the wrong receiver. Fix: switch to first-wins `setTypeMapEntry` to match Rust extractor semantics. First-wins preserves the interface annotation that drives correct CHA dispatch. Bug 2 — native vs wasm/hybrid confidence mismatch: `runPostNativeCha` (native orchestrator path) used `computeConfidence − CHA_DISPATCH_PENALTY = 0.7 − 0.1 = 0.6`, while `runChaPostPass` (DB post-pass used by wasm and hybrid) hardcodes 0.8. Fix: align `runPostNativeCha` to also use 0.8. Result: all three build paths now emit identical edges and confidences. `parity-compare.mjs --langs java --hybrid` passes. Updated expected-edges.json to include both the interface declaration edge (TypeRepository.X at 0.7) and the CHA-expanded impl edge (InMemoryUserRepository.X at 0.8), which are the correct semantics for an interface-typed receiver. Closes #1469 * fix: use setTypeMapEntry in cpp/cuda extractors and extract CHA_DISPATCH_CONFIDENCE constant Switch handleCppDeclaration and handleCudaDeclaration from last-wins ctx.typeMap.set() to first-wins setTypeMapEntry(), fixing the same flat typeMap pollution bug this PR corrects in java.ts. Extract the CHA dispatch confidence value to a named CHA_DISPATCH_CONFIDENCE constant in helpers.ts so runChaPostPass and the native orchestrator share a single source of truth instead of two synchronized magic numbers. * fix(native): extend Gate B RTA check to cover constructor/function kinds (#1503) The incremental scoping gate in runPostNativeCha only checked tgt.kind = 'class' for new RTA evidence, but the adjacent two-tier RTA collection also falls back to constructor/function kinds for databases written by older native engine versions. On such incremental builds Gate B would never fire, causing the scan to be wrongly scoped to changed files and missing CHA expansion for unchanged-file call sites.
1 parent 1961363 commit 55f9150

4 files changed

Lines changed: 12 additions & 11 deletions

File tree

src/domain/graph/builder/helpers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,9 @@ export function batchInsertEdges(db: BetterSqlite3Database, rows: unknown[][]):
361361
}
362362
}
363363

364+
/** Confidence assigned to CHA-expanded interface/abstract dispatch edges. */
365+
export const CHA_DISPATCH_CONFIDENCE = 0.8;
366+
364367
/**
365368
* CHA (Class Hierarchy Analysis) post-pass.
366369
*
@@ -514,7 +517,7 @@ export function runChaPostPass(db: BetterSqlite3Database): number {
514517
const key = `${source_id}|${methodNode.id}`;
515518
if (seen.has(key)) continue;
516519
seen.add(key);
517-
newEdges.push([source_id, methodNode.id, 'calls', 0.8, 0, 'cha']);
520+
newEdges.push([source_id, methodNode.id, 'calls', CHA_DISPATCH_CONFIDENCE, 0, 'cha']);
518521
}
519522
}
520523

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import type { PipelineContext } from '../context.js';
4949
import {
5050
batchInsertEdges,
5151
batchInsertNodes,
52+
CHA_DISPATCH_CONFIDENCE,
5253
collectFiles as collectFilesUtil,
5354
fileHash,
5455
fileStat,
@@ -570,8 +571,8 @@ function runPostNativeCha(
570571
}
571572

572573
// Find existing call edges targeting qualified methods (e.g., 'IWorker.doWork').
573-
// Include the caller node's file so confidence can be computed file-pair-aware,
574-
// matching the WASM path's computeConfidence(callerFile, targetFile, null) - CHA_DISPATCH_PENALTY formula.
574+
// Include caller_file and method_file so affectedFiles can be populated for
575+
// incremental role reclassification; confidence is CHA_DISPATCH_CONFIDENCE matching runChaPostPass.
575576
// When scopeToChangedFiles is true, restrict to call sites in the changed files
576577
// (safe because no hierarchy or RTA evidence changed outside those files).
577578
let callToMethods: Array<{ source_id: number; method_name: string; caller_file: string | null }>;
@@ -667,10 +668,7 @@ function runPostNativeCha(
667668
const key = `${source_id}|${methodNode.id}`;
668669
if (seen.has(key)) continue;
669670
seen.add(key);
670-
// Use the same hardcoded 0.8 that runChaPostPass (helpers.ts) uses for
671-
// DB-level CHA dispatch edges. This aligns the native orchestrator path
672-
// with the WASM and hybrid paths, which both go through runChaPostPass.
673-
const conf = 0.8;
671+
const conf = CHA_DISPATCH_CONFIDENCE;
674672
newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha']);
675673
newEdgeCount++;
676674
if (caller_file) affectedFiles.add(caller_file);

src/extractors/cpp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
TreeSitterNode,
66
TreeSitterTree,
77
} from '../types.js';
8-
import { extractModifierVisibility, findChild, nodeEndLine } from './helpers.js';
8+
import { extractModifierVisibility, findChild, nodeEndLine, setTypeMapEntry } from './helpers.js';
99

1010
/**
1111
* Extract symbols from C++ files.
@@ -236,7 +236,7 @@ function handleCppDeclaration(node: TreeSitterNode, ctx: ExtractorOutput): void
236236
if (!nameNode) continue;
237237
const varName = unwrapCppDeclaratorName(nameNode);
238238
if (varName) {
239-
ctx.typeMap.set(varName, { type: typeName, confidence: 0.9 });
239+
setTypeMapEntry(ctx.typeMap, varName, typeName, 0.9);
240240
}
241241
}
242242
}

src/extractors/cuda.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
TreeSitterNode,
66
TreeSitterTree,
77
} from '../types.js';
8-
import { extractModifierVisibility, findChild, nodeEndLine } from './helpers.js';
8+
import { extractModifierVisibility, findChild, nodeEndLine, setTypeMapEntry } from './helpers.js';
99

1010
/**
1111
* Extract symbols from CUDA files.
@@ -236,7 +236,7 @@ function handleCudaDeclaration(node: TreeSitterNode, ctx: ExtractorOutput): void
236236
if (!nameNode) continue;
237237
const varName = extractCudaFieldName(nameNode);
238238
if (varName) {
239-
ctx.typeMap.set(varName, { type: typeName, confidence: 0.9 });
239+
setTypeMapEntry(ctx.typeMap, varName, typeName, 0.9);
240240
}
241241
}
242242
}

0 commit comments

Comments
 (0)