bug: resolution is STICKY across incremental runs — renaming an anchor file leaves refs resolved that a cold index leaves unresolved #52
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
h-dv/code-index#52
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by the new corpus cold==incremental suite (#42) on its first real run — the first defect the OSS corpus has produced. Found at v0.8.7 @
855710d.Symptom
Rename
Dapper/SqlMapper.cs→Dapper/SqlMapper_renamed.csin the pinnedcs-dappercorpus repo (a semantically meaningless change: C# does not tie type names to filenames). Then:Dapper/CommandDefinition.cs:133:41CommandTimeout(qualifierSqlMapper.Settings)Dapper/SqlMapper.Settings.cs#CommandTimeout@76~UNRESOLVED~3 refs affected in total (
CommandTimeout×2,FetchSize×1), all inCommandDefinition.cs, all pointing atSqlMapper.Settings.cs.The same working tree yields two different answers depending on edit history.
Repro
Or:
cargo test --release -p code-index-indexer --test corpus_metamorphic.Root cause
Two mechanisms combine.
1. Resolution is sticky. Every resolve tier in
crates/indexer/src/index.rsis gated onrefs.target_id IS NULL(lines 765, 1170, 1454, 1587, 1605, 1683). An already-resolved ref is never re-evaluated.2. Invalidation only tracks symbol NAMES, not evidence. The sole un-resolver is the stale-name refill (
index.rs:367):stale_outline_namesholds names whose symbol outline changed. But the I025 qualifier anchoring derives evidence from the file path:temp.file_keysis populated fromPath::file_stem(index.rs:879), andtemp.qual_anchor_filesanchors a qualifier on a file whose key equalsq_first(index.rs:1274-1280). SoDapper/SqlMapper.cs(stemSqlMapper) is what anchors the qualifierSqlMapper.Settings.Renaming that file destroys the anchoring evidence — but the name
CommandTimeoutnever became stale, because its defining file (SqlMapper.Settings.cs) was untouched. So the refs keep their stale target.Why it only shows up here
The trigger requires anchor file ≠ target file. Measured across all 7 tier-1 corpus repos, renaming the hottest file: 0 incoherent refs everywhere. In Rust/Python/TS/JS/PHP/Ruby the anchor file usually is the target file (
flags::defs::Xanchors ondefs.rs, whereXalso lives) — so renaming it re-mintsX, its name goes stale, the refill fires, and coherence is preserved by accident.C# partial classes split
SqlMapperacrossSqlMapper.csandSqlMapper.Settings.cs, decoupling anchor from target and exposing the gap. Any language allowing that split is affected.Severity: medium — coherence, NOT a phantom
find_references/find_callersresults depend on your edit history rather than on the tree, and a long-lived daemon drifts from what a fresh index would say. That is the same incoherence class that made I028 and I034 expensive.Fix direction (NOT attempted — resolver changes need the full review process)
The narrow candidate: when any file is added/deleted/renamed in a batch, also NULL
target_idfor resolved refs withqualified = 1before the Full pass, so anchored resolutions are re-derived from current evidence. Bounded set; a no-op on cold indexes.Deliberately not implemented in the discovering session. The resolver is the crown jewel and this project's history is explicit that resolver fixes need adversarial review before shipping — I037's first fix was wrong and the motivating phantom survived it. This wants its own mission with the precision gate + corpus suite as the clamp.
Interim handling
The corpus rename suite enumerates these 3 exact ref sites as a documented known incoherence citing this issue — the same device as
oracle.toml'sname_fallback_ceiling. Anything new, anywhere, still fails the gate.Acceptance
Correction — the issue as filed is wrong in two ways, and the defect is broader
Investigation + independent reproduction. Three claims in the original description do not survive.
1. It is NOT C#-specific
Filed as triggered by C# partial classes. It reproduces in all six languages with a 3-file hermetic fixture — no corpus needed. Verified in Rust directly:
The general shape is: a third file's stem supplies
temp.qual_knownroot plausibility (index.rs:1252-1267), unlocking the parent-anchor arm (index.rs:1326-1336); the target lives in neither the ref's file nor the anchor file. C# partial classes are one way to get there, not the only one.2. The anchor file needs NO SYMBOLS — so there is no Full pass
qual_knowntests only for afile_keysrow, andfile_keysgets a stem row for every code file regardless of content (index.rs:879-890). Verified above:symbols in foo.rs = 0, and it still anchors.This is worse than filed. A symbol-less file contributes nothing to
stale_outline_nameson delete (writer.rs:336-370), so renaming it takes the Scoped pass — orSkippedentirely. The original description assumed a Full pass was running and merely failing to invalidate enough. It often isn't running at all.3. The proposed fix, applied naively, causes PERMANENT RECALL LOSS
The description proposed NULLing
qualified = 1resolutions when paths change. Simulated that firing outside a Full pass:The scope predicate (
index.rs:664-666) only re-resolves refs whose file is in scope or whose name matches a symbol in a scope file. A NULLed ref outside that set is never revisited, andapply_resolutionshort-circuits toSkipped(index.rs:349-352). On a long-lived daemon this is monotonic, silent recall decay — strictly worse than the bug being fixed.Hard constraint on any fix: invalidation may only ever fire in the same transaction as a guaranteed Full pass.
What IS confirmed
qualified = 1 AND qualifier IS NOT NULLis correct — but for a different reason than I gave. I initially thought tier 1b'sfile_keys/file_pkgjoins (index.rs:1025/1028/1035/1044) made it insufficient. They don't: those joins are keyed on the ref's file and the candidate's file only, and both are re-minted or name-refilled when they change, so they self-heal. Same for tier 1R (index.rs:1869/1886/1896, keyed onm.file_id/rb.file_id). Third-file evidence is confined to tier 1Q, whose own gate is exactly this predicate (index.rs:1170-1171,1454-1455). Sizing on this repo: 862 of 7817 resolved refs;qualifier IS NOT NULLalone would over-invalidate 2.2×.target_id IS NULL-gated). Spec 04's contract is explicit — seem0019_pool_hygiene_reheal.rs:4-8. Current schema is 22; this needs an m0023 resolve-only re-heal, and the heal must be the wide NULL-everything form, not the fix's own predicate, or the migration test becomes tautological.Revised acceptance
refs_invalidatedreported as a MEASURED count, not derivedSkippedwith zero invalidationknown_incoherenceincorpus_metamorphic.rsempties outFixed
The change
A new persistent pending table
stale_path_evidencerecords the tier-1Q anchor keys (file stem + package tail) a code file contributes, written whenever a path JOINS or LEAVES the code-file set.apply_resolutionthen, in the same IMMEDIATE transaction:instr(qualifier, key) > 0, a strict superset of everyqual_knownarm (each requires the key to appear literally inside the qualifier), so it cannot miss a case while needing no knowledge of the segmentation rules;qualified = 1resolutions and forcesResolveScope::Full;Steps 2's two halves are deliberately inseparable. The UPDATE fixes the rename direction; forcing Full fixes the add direction (
Scoped's predicate is file-local and structurally cannot apply a newly-available anchor to an untouched file). And forcing Full is what makes the UPDATE safe — NULLing under a Scoped pass loses the resolution permanently.Migrations:
m0024_anchor_path_evidencecreates the table and runs the resolve-only re-heal (wide NULL-everything, deliberately not the fix's own predicate, so the migration asserts agreement with a cold index rather than with itself). It is ordered afterm0023_symbols_parent_name_indexbecause that index halves the re-heal's cost (46.7s → 25.3s on rust-analyzer).Adversarial review — could not break the core fix
Verified by an independent reviewer that built repros rather than reasoning:
qualified = 1is the right predicate. Every other path-derived input (tier 1b same-dir/file-key/pkg arms, tier 3's reachability, tier 1R's origin gate) keys on the ref's own file or the candidate's own file, both of which self-heal via re-mint + the stale-name refill. No third-file path change flipping an unqualified resolution could be constructed.Six findings, all fixed
write_deletearm had zero coverage — it could be deleted with the whole suite green (every test reached it via a rename, which also fires the upsert arm)plain_delete_of_a_symbol_less_anchor_matches_cold_indexcorpus_cold_equals_incremental_addwas vacuous — it passed with the entire fix disabled, because its probe stem was chosen so "no source refers to it", which structurally cannot make a qualifier root-plausiblestd,Handler,bob) and honestly skips 4write_stat_touchre-stampskindwith no guard, andwrite_upsertonly fired on transitions into the code setnote_path_evidence; empty keys rejected (instr(x,'')is 1 in SQLite and would match every qualifier)busy_timeoutinstrrelevance test above; an irrelevant path change now clears the signal and returnsSkippedoutright, verified byirrelevant_path_change_does_not_force_a_full_passRunCounters.refs_invalidatedIndexStats.refs_invalidatedremains and is asserted on)F4's fix produced a further improvement found only by writing its test: an irrelevant path change previously still ran a near-empty
Scopedpass and left the signal populated. It now skips and clears.Acceptance
unrelated_body_edit_never_loses_an_anchored_resolutionruns 3 body edits assertingScoped,refs_invalidated == 0and target stabilityrefs_invalidatedMEASURED from the UPDATE's row count, never derivedSkippedwith zero invalidationknown_incoherenceis empty and the rename suite runs withtolerate_known: falseHonest limitation: the matrix is 3 languages, not 6
The earlier claim that this reproduces in all six was based on a table of file/ref names without source bodies. I could construct and verify the decoupled tier-1Q shape in rust, python and php only. Every TypeScript, Ruby and C# fixture I tried either captured no qualifier (
qualified = 0) or resolved through a self-healing tier — so including them would have meant asserting on a shape that was not present.anchor_rename.rs::assert_shapefails loudly rather than silently passing if someone adds such a fixture.The invalidation itself is one language-agnostic SQL predicate, so this is a gap in regression coverage for qualifier capture, not a gap in the fix. C#'s real instance — the one that found this bug — remains covered end-to-end by the corpus rename suite over pinned cs-dapper.
Fixed and released in v0.9.0 (schema v25, migration
m0024_anchor_path_evidence).Tier 1Q anchored qualified refs on file stems with no invalidation, so renaming an anchor file left resolutions a cold index would decline — a coherence defect, not a phantom one. Fixed by adding a third pending count (
stale_path_evidence) inapply_resolution, in both the fast path and the in-transaction re-read, gated by aninstr()relevance test so an irrelevant path change doesn't force a full re-resolve (the first cut regressed a single-file delete from 17ms to 17.9s).Note for the record: the fix originally filed on this issue would have caused permanent recall loss. Reproducing the failure mode first is what caught that.
Regression cover:
crates/indexer/tests/anchor_rename.rs(rust/python/php matrix, with a positive control asserting the anchor stays symbol-less) pluscorpus_metamorphic::corpus_cold_equals_incremental_renameover the pinned cs-dapper repo, which is where this was originally found.