Skip to content

Commit 6e09787

Browse files
authored
fix(wasm): align TypeScript CHA dispatch confidence (0.6 → 0.8) (#1505)
* 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(wasm): align typed-receiver CHA dispatch confidence to 0.8 The inline CHA expansion in buildCallEdges and buildChaPostPass used computeConfidence(relPath, t.file) - CHA_DISPATCH_PENALTY for all CHA targets, producing 0.6 for cross-directory interface dispatch (same-dir = 0.7, minus 0.1 penalty). runChaPostPass (helpers.ts) and runPostNativeCha (native-orchestrator.ts) both hardcode 0.8 for interface/CHA-dispatch edges. The deduplication in runChaPostPass uses the existing DB edge as-is and skips reinsertion, so the 0.6 edges from the inline pass were never upgraded to 0.8. Fix: typed-receiver (interface) dispatch branches now use hardcoded 0.8 matching the post-pass constants. The this/super branch keeps computeConfidence-based proximity scoring to remain aligned with runPostNativeThisDispatch. parity-compare.mjs --langs typescript --hybrid goes green (was 12 edge diffs). Closes #1470 docs check acknowledged * 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. * refactor: extract CHA_TYPED_DISPATCH_CONFIDENCE named constant Move CHA_DISPATCH_PENALTY to helpers.ts alongside the new CHA_TYPED_DISPATCH_CONFIDENCE = 0.8 constant so all four CHA dispatch sites (helpers.ts runChaPostPass, build-edges.ts buildChaPostPass, build-edges.ts buildFileCallEdges, native-orchestrator.ts runPostNativeCha) reference a single named export instead of repeating the literal. native-orchestrator.ts now imports both constants from helpers.ts, removing the build-edges.ts import.
1 parent 55f9150 commit 6e09787

3 files changed

Lines changed: 33 additions & 26 deletions

File tree

src/domain/graph/builder/helpers.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export const BUILTIN_RECEIVERS: Set<string> = new Set([
4848
'require',
4949
]);
5050

51+
/** Phase 8.5: confidence penalty applied to CHA-dispatch edges. */
52+
export const CHA_DISPATCH_PENALTY = 0.1;
53+
/** Phase 8.5: fixed confidence for typed-receiver (interface/CHA) dispatch edges.
54+
* File proximity is not meaningful for virtual dispatch — all three engine paths
55+
* (WASM inline, WASM post-pass, native post-pass) must agree on this value. */
56+
export const CHA_TYPED_DISPATCH_CONFIDENCE = 0.8;
57+
5158
/** Check if a directory entry should be skipped (ignored dirs, dotfiles). */
5259
function shouldSkipEntry(entry: fs.Dirent, extraIgnore: Set<string> | null): boolean {
5360
if (entry.name.startsWith('.') && entry.name !== '.') {
@@ -517,7 +524,14 @@ export function runChaPostPass(db: BetterSqlite3Database): number {
517524
const key = `${source_id}|${methodNode.id}`;
518525
if (seen.has(key)) continue;
519526
seen.add(key);
520-
newEdges.push([source_id, methodNode.id, 'calls', CHA_DISPATCH_CONFIDENCE, 0, 'cha']);
527+
newEdges.push([
528+
source_id,
529+
methodNode.id,
530+
'calls',
531+
CHA_TYPED_DISPATCH_CONFIDENCE,
532+
0,
533+
'cha',
534+
]);
521535
}
522536
}
523537

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

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,13 @@ import {
4545
import type { ChaContext } from '../cha.js';
4646
import { buildChaContext, resolveChaTargets, resolveThisDispatch } from '../cha.js';
4747
import type { PipelineContext } from '../context.js';
48-
import { BUILTIN_RECEIVERS, batchInsertEdges, runChaPostPass } from '../helpers.js';
48+
import {
49+
BUILTIN_RECEIVERS,
50+
batchInsertEdges,
51+
CHA_DISPATCH_PENALTY,
52+
CHA_TYPED_DISPATCH_CONFIDENCE,
53+
runChaPostPass,
54+
} from '../helpers.js';
4955
import { getResolved, isBarrelFile, resolveBarrelExportCached } from './resolve-imports.js';
5056

5157
// ── Local types ──────────────────────────────────────────────────────────
@@ -101,9 +107,6 @@ interface NativeEdge {
101107
dynamic: number;
102108
}
103109

104-
/** Phase 8.5: confidence penalty applied to CHA-dispatch edges. */
105-
export const CHA_DISPATCH_PENALTY = 0.1;
106-
107110
// ── Node lookup setup ───────────────────────────────────────────────────
108111

109112
function makeGetNodeIdStmt(db: BetterSqlite3Database): NodeIdStmt {
@@ -735,13 +738,12 @@ function buildChaPostPass(
735738
for (const t of chaTargets) {
736739
const edgeKey = `${caller.id}|${t.id}`;
737740
if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
738-
// Typed-receiver (interface/CHA) dispatch: use the same hardcoded 0.8 that
739-
// runChaPostPass (helpers.ts) and runPostNativeCha (native-orchestrator.ts)
740-
// use — file proximity is not meaningful for virtual dispatch confidence.
741+
// Typed-receiver (interface/CHA) dispatch: use CHA_TYPED_DISPATCH_CONFIDENCE
742+
// — file proximity is not meaningful for virtual dispatch confidence.
741743
// this/super dispatch keeps computeConfidence-based proximity scoring to
742744
// match runPostNativeThisDispatch (native-orchestrator.ts).
743745
const conf = isTypedReceiverDispatch
744-
? 0.8
746+
? CHA_TYPED_DISPATCH_CONFIDENCE
745747
: computeConfidence(relPath, t.file, null) - CHA_DISPATCH_PENALTY;
746748
if (conf > 0) {
747749
seenByPair.add(edgeKey);
@@ -1327,13 +1329,12 @@ function buildFileCallEdges(
13271329
for (const t of chaTargets) {
13281330
const edgeKey = `${caller.id}|${t.id}`;
13291331
if (t.id !== caller.id && !seenCallEdges.has(edgeKey) && !ptsEdgeRows.has(edgeKey)) {
1330-
// Typed-receiver (interface/CHA) dispatch: use the same hardcoded 0.8 that
1331-
// runChaPostPass (helpers.ts) and runPostNativeCha (native-orchestrator.ts)
1332-
// use — file proximity is not meaningful for virtual dispatch confidence.
1332+
// Typed-receiver (interface/CHA) dispatch: use CHA_TYPED_DISPATCH_CONFIDENCE
1333+
// — file proximity is not meaningful for virtual dispatch confidence.
13331334
// this/super dispatch keeps computeConfidence-based proximity scoring to
1334-
// match runPostNativeThisDispatch (native-orchestrator.ts line 906).
1335+
// match runPostNativeThisDispatch (native-orchestrator.ts).
13351336
const conf = isTypedReceiverDispatch
1336-
? 0.8
1337+
? CHA_TYPED_DISPATCH_CONFIDENCE
13371338
: computeConfidence(relPath, t.file, null) - CHA_DISPATCH_PENALTY;
13381339
if (conf > 0) {
13391340
seenCallEdges.add(edgeKey);

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

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@ import type { PipelineContext } from '../context.js';
4949
import {
5050
batchInsertEdges,
5151
batchInsertNodes,
52-
CHA_DISPATCH_CONFIDENCE,
52+
CHA_DISPATCH_PENALTY,
53+
CHA_TYPED_DISPATCH_CONFIDENCE,
5354
collectFiles as collectFilesUtil,
5455
fileHash,
5556
fileStat,
5657
readFileSafe,
5758
} from '../helpers.js';
5859
import { NativeDbProxy } from '../native-db-proxy.js';
59-
import { CHA_DISPATCH_PENALTY } from './build-edges.js';
6060
import { closeNativeDb } from './native-db-lifecycle.js';
6161

6262
// ── Native orchestrator types ──────────────────────────────────────────
@@ -572,7 +572,7 @@ function runPostNativeCha(
572572

573573
// Find existing call edges targeting qualified methods (e.g., 'IWorker.doWork').
574574
// Include caller_file and method_file so affectedFiles can be populated for
575-
// incremental role reclassification; confidence is CHA_DISPATCH_CONFIDENCE matching runChaPostPass.
575+
// incremental role reclassification; confidence uses CHA_TYPED_DISPATCH_CONFIDENCE matching runChaPostPass.
576576
// When scopeToChangedFiles is true, restrict to call sites in the changed files
577577
// (safe because no hierarchy or RTA evidence changed outside those files).
578578
let callToMethods: Array<{ source_id: number; method_name: string; caller_file: string | null }>;
@@ -668,7 +668,7 @@ function runPostNativeCha(
668668
const key = `${source_id}|${methodNode.id}`;
669669
if (seen.has(key)) continue;
670670
seen.add(key);
671-
const conf = CHA_DISPATCH_CONFIDENCE;
671+
const conf = CHA_TYPED_DISPATCH_CONFIDENCE;
672672
newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha']);
673673
newEdgeCount++;
674674
if (caller_file) affectedFiles.add(caller_file);
@@ -955,14 +955,6 @@ interface PostPassTimings {
955955
techniqueBackfillMs: number;
956956
}
957957

958-
interface PostPassTimings {
959-
gapDetectMs: number;
960-
chaMs: number;
961-
thisDispatchMs: number;
962-
reclassifyMs: number;
963-
techniqueBackfillMs: number;
964-
}
965-
966958
/** Format timing result from native orchestrator phases + JS post-processing. */
967959
function formatNativeTimingResult(
968960
p: Record<string, number>,

0 commit comments

Comments
 (0)