fix: coverage-domain artifacts stale on Windows incremental (path-spelling split) - #681
Conversation
The todos/licenses/codeowners/codegen/fixtures/modules builders re-spelled the caller's relPath with filepath.ToSlash before minting node IDs, FilePath fields, and edge endpoints. Everything else in the ingest pipeline keys file identity by the exact relPath spelling the indexer uses - OS-native separators for subdirectory files on Windows (see graphRelKey) - so on Windows these artifacts were invisible to eviction (nodes are matched by file_path, edges by evicted-endpoint touch) and their file-side endpoints dangled from nonexistent node IDs. Observable damage on Windows: editing a file left its stale todo node (old tag/text) and annotated edge in the store; the standalone fixture node duplicated the file identity instead of sharing it; licensed_as / owns / generated_by / depends_on_module edges for subdirectory files dangled from birth. POSIX behavior is byte-identical (ToSlash is the identity there). The builders now preserve the caller's spelling verbatim; each carries a contract comment saying why. One spelling-preservation test per builder pins the contract with a platform-native path.
Stores written on Windows before the previous commit hold coverage rows (todo nodes, fixture nodes, and their edges) keyed by the forward-slash spelling of subdirectory files. Both removal lanes - the structural replace commit and the deleted-file eviction - funnel through evictFilesBatched, so the twin sweep lives there: for every path whose ToSlash form differs, the slash spelling is evicted too. Nothing else ever minted a re-spelled FilePath, so the twin can only match pre-fix coverage rows; on POSIX the twin equals the native spelling and the sweep adds nothing. With this, a stale slash-spelled todo row heals on the file''s next edit or deletion - exactly the moments its content could have changed. Two end-to-end regressions pin both lanes: a seeded pre-fix row is swept on replacement (and the fresh extraction''s native-spelled todo lands) and on deletion.
zzet
left a comment
There was a problem hiding this comment.
Thanks for working through this Windows path-spelling issue. I confirmed the scanner-side caller-path preservation is consistent, but there are correctness problems in the incremental healing path that need to be addressed before merge.
P1 — Generic slash-twin eviction can corrupt shared coverage artifacts
internal/indexer/incremental_batch.go:924 sends the legacy slash spelling through generic endpoint-touch eviction. Coverage targets such as license::MIT, owners, generators, and modules can be shared by multiple files.
I reproduced both outcomes with the in-memory and SQLite backends using native A = src\\a.go, legacy A = src/a.go, native B = src\\b.go, with A and B both linked to license::MIT:
- If the shared node is anchored to A's legacy path, evicting A removes B's valid
licensed_asedge (expected 1, actual 0). - If the shared node is anchored to B, A's stale legacy edge survives (expected 0, actual 1).
Please avoid passing legacy spellings to generic file eviction. Delete legacy coverage edges selectively by provenance/Edge.FilePath and edge kind, preserve shared targets while referenced, and remove those targets only when orphaned. Please add Graph and SQLite tests covering both anchoring orders.
P1 — Existing Windows stores are not migrated
The healing runs only for changed/deleted paths, and this PR has no extractor/store-policy version bump or startup migration. Upgrading and running an incremental pass without file changes therefore leaves legacy TODO/fixture nodes and licensed_as, owns, generated_by, and depends_on_module edges indefinitely. Edge-only residue can also survive a later edit when neither endpoint is selected for removal.
Please add a versioned coverage-artifact migration, or bump a global policy version to force one safe rebuild. A persisted SQLite pre-fix-store -> new-binary reopen test with no changed files would cover this.
P2 — The added tests do not exercise the Windows path split
In internal/indexer/incremental_reindex_test.go, the tests derive native paths with filepath.Join and legacy paths with filepath.ToSlash. On hosted Ubuntu/macOS those strings are identical, so the new tests would pass before this patch and never execute the twin-path branch. The Windows CI job only cross-compiles; it does not run the tests.
Please run the affected tests on Windows, or extract a platform-parametric helper testable with explicit src\\a.go and src/a.go spellings. Include Graph and SQLite persisted-store upgrade cases.
The affected-package race tests and go vet pass, and I found no security or dead-code concerns. The remaining blocker is persisted graph correctness.
Review found that routing the forward-slash twin through generic file eviction corrupts shared coverage targets. Endpoint-touch eviction cannot tell a per-file artifact from a license/team/module node that many files reference: with the shared node anchored to the legacy path the sweep deletes it along with OTHER files' valid edges, and with it anchored elsewhere the stale legacy edge survives regardless. The reviewer reproduced both directions; a both-backends regression here reproduces them too (and shows the shared node itself disappearing). The sweep is reverted. Legacy rows are instead healed once, by schema migration v13, which is what the incremental path could never be: selective. It deletes coverage edges by kind AND their own FilePath spelling, clears the per-file todo/fixture nodes with their endpoints and their symbol-FTS rows, and removes a shared target only after the purge leaves it with no references at all. This also closes the residue the incremental sweep left by design: a file nobody edits again never healed, because healing only ran for changed or deleted paths. Three guards keep the purge off rows it must not touch. It runs only on a store holding backslash-bearing paths (a store written on POSIX is untouched); it judges a path only below the repo prefix, where a Windows-written store spells separators with a backslash, so top-level files - never damaged - never match; and it considers only the six coverage domains' own kinds.
Review found the six tests inert on the hosted runners, and the reason generalizes: they built the path with filepath.Join, but the call the fix removed was filepath.ToSlash, which is the identity on POSIX. Both spellings collapse to one string there, so the assertions held with or without the fix, and the Windows CI job only cross-compiles. Each test now names the spelling under test as a literal carrying a backslash. That does not make the tests bind the ToSlash regression on POSIX - nothing can, since the removed call was a no-op there - but it does pin a byte-identity contract that holds on every platform: the builder returns the caller's spelling verbatim. Any future unconditional re-spelling, whether ToSlash or strings.ReplaceAll or path.Clean, fails these on every runner rather than only on Windows. Binding the original regression itself remains a Windows-only run. The fixtures case keeps a forward slash on the qualifying testdata segment, because IsFixturePath normalizes through filepath.ToSlash, and asserts only ID and FilePath: Name comes from filepath.Base, whose separator set is the running platform's, and TestBuildGraphArtifacts already covers it.
The regression carries two shared license nodes - one whose first-sighting FilePath is the legacy spelling, one anchored to a surviving file - and asserts the same outcome for both. Which file a shared coverage target happens to name must not change what evicting another file removes. Both storage backends run the case. Restoring the twin sweep fails it on both backends: b's valid licensed_as edge disappears and the shared node itself is deleted.
Adversarial review of the v13 migration found two ways it could delete live rows, both traced to judging a path by a store-wide rule. The first is a data-loss path. A fixture node reuses the file node's ID by design (internal/fixtures: "the fixture is the file", and ReclassifyFileToFixture upgrades a file node in place). The guard asked only whether the STORE held a backslash path anywhere, so a store carrying a POSIX-indexed repository beside a Windows-indexed one judged both by the Windows rule - and deleted the POSIX repository's fixture nodes, which ARE its file nodes, orphaning every symbol they define. The scope is now per repository: a repository is judged only when its own paths are backslash-spelled. The second is a near miss. The Go externals lane mints live depends_on_module edges whose FilePath is a synthetic namespace, external::go:<importPath> (goanalysis.externalFilePath). That value carries the import path's own forward slashes, which the predicate read as separators. Paths containing "::" are now excluded on every arm: that sequence marks a stub namespace, not a file. On a single-repo store, where the unprefixed arm is active, this was live third-party attribution one upgrade away from deletion. Two smaller gaps closed while here. Node-keyed sidecars are cleaned for purged nodes - ref_facts by both endpoints, plus vectors, constant values and the churn / coverage / release / blame enrichment tables - mirroring what the eviction path clears through its callers and what purgeUnprefixedRepoRows does for vectors. And the persisted analysis generation is invalidated when its tables exist, for the same reason evictByPredicateResult invalidates it: it was computed over rows the purge just removed. The tests grew with the code: a mixed-platform store whose POSIX repository must come through untouched, the synthetic-namespace cases in both prefixed and single-repo form, a non-coverage edge carrying the legacy spelling that must survive, orphan removal through the owns and generated_by arms, and a direct table test of the predicate across several repositories including a prefix that is a leading substring of another. Each was verified to fail against the corresponding mutation.
…dict A second review pass found the per-repository verdict is sticky. It is drawn from rows eviction never removes - that is this migration's whole premise - so a repository once indexed on Windows keeps that verdict even after being re-indexed on POSIX inside the same store, which is reachable through a synced or container-mounted home directory. Since a fixture node reuses the file node's ID, the purge would then delete a live file and orphan every symbol in it. Rather than chase the classification, the node selection now excludes any node that defines symbols. A legacy artifact node defines nothing; a file node with symbols in it always does. The damage from a wrong verdict is bounded to zero at a cost of one NOT EXISTS. Two path-shape fixes alongside it. The scope query reads only file nodes: they are the only rows that evidence the indexer's separator, and the restriction lets it ride the covering nodes_repo_files index instead of fetching every row of a table whose payload carries the meta blob (measured 4.9x cheaper). And a repository prefix may itself contain a separator, so each arm now excludes the paths of any repository nested beneath it, which would otherwise have their prefix separator read as a legacy spelling. Finally, the node-keyed cleanup is skipped outright when nothing is doomed. It runs before healPlannerStats, so on a store with no sqlite_stat1 the ref_facts deletes plan as full scans of a table holding one row per resolved reference edge - work no store with nothing to heal should pay. clone_shingles joins the sidecar list, and a test now derives the required set from the live schema so the list cannot drift.
The defines fail-safe from the previous commit turned out to guard almost nothing. Writing the review brief for an independent pass made the hole obvious: a defines edge only exists if the file has parsed symbols, and the fixture files this is meant to protect are data, not code. Measured on a real store: only 34 of 1,126 legacy artifact rows carry a defines edge, and 1 of 35 live fixture nodes has none - exactly the case the guard was written for. The requirement is now the one that actually separates the two populations. A legacy row is a RE-spelling of a file the indexer also recorded natively, so the natively spelled twin is in the store; a file merely indexed on POSIX has no twin, because its own spelling IS the native one. Requiring the twin removes exactly the duplicates and can never remove a live row, fixture aliasing included, whatever verdict the repository scope reached. It costs no healing: all 1,126 legacy-spelled artifact rows on that store have their twin, and the delete set is unchanged at 1,088 nodes and 1,088 edges. What it gives up is a legacy row whose file was deleted before the upgrade, whose twin went with it. That row is unreachable residue either way, and trading it for the guarantee that no live file is ever removed is the right side of the bargain. The path arms stay in legacyPathPredicate so they remain independently testable; legacyRowPredicate is what the migration selects on.
An independent review pass found the twin requirement unsound, with a counterexample I could reproduce. A store written on Windows and then re-indexed on POSIX - a synced home directory, a container mounting the host store - keeps its stale Windows rows, because eviction is spelling-exact and that is this migration's own premise. Those stale rows then set the repository's Windows verdict AND vouch for the new forward-slash rows as their twin, so a LIVE POSIX todo and a stale legacy todo become identical in every path field. The live one was deleted, with its coverage edges. A POSIX filename legally containing a backslash produces the same collision in a single-platform store. Both are now regression tests, and both failed before this commit. Path shape cannot answer the question, so the predicate no longer asks it alone. When the indexer parses a file it records that path in `files` and hangs the file node and every symbol in it off that same path; a coverage builder's re-spelling was never any of those things. A row is legacy only when its natively spelled twin exists, `files` does not record its path, and no node outside the coverage domains claims it. The candidate excludes itself from that last test, since a fixture node IS its own path - which is also the case the `files` check covers, because a fixture with no symbols in it has no other claimant. Measured on a real Windows store: of 1,088 legacy artifact rows, zero had their path in `files` and zero were claimed by a non-coverage node, while all 1,088 twins had both. The delete set is unchanged at 1,088 nodes and 1,088 edges, selected in 18 ms and 40 ms against a 4.3 GB store, and the ambiguous cases are excluded by construction rather than by argument. Note for anyone extending this: file_mtimes is NOT usable as the ownership test. It is keyed repo-relative and slash-normalized, so a legacy path matches it and a native path does not - the exact inversion of the signal wanted here.
|
Thanks for the repro on the eviction path - both directions land, and the second one (shared node anchored elsewhere, stale edge survives) is the half I had talked myself out of during the build. P1-1: the twin sweep is goneI reverted it rather than making it selective in place. Generic endpoint-touch eviction has no way to tell a per-file artifact from a shared coverage target, and pushing that knowledge into
P1-2: healing moved to schema migration v13
Three guards bound what is judged. A repository is judged only when its OWN file nodes are backslash-spelled, so a repository indexed on POSIX is untouched even when it shares a store with a Windows-indexed one. Within such a repository, only a forward slash BELOW the repo prefix marks a row no current builder could have produced, so top-level files - which were never damaged - never match. And selection is by the six coverage domains' own kinds; nothing else is ever a candidate. Those three are necessary and not sufficient, which took me two rounds of review to accept. Path shape cannot decide staleness on its own: a store written on Windows and then re-indexed on POSIX keeps its stale Windows rows, since eviction is spelling-exact - this migration's own premise - and those stale rows then both set the repository's verdict and vouch for the new forward-slash rows as their twin. At that point a live POSIX row and a stale legacy row are identical in every path field. A POSIX filename that legally contains a backslash produces the same collision inside a single-platform store. So the predicate asks an ownership question instead. When the indexer parses a file it records that path in (One trap worth recording if you touch this: This also closes the residue the incremental sweep left by design: a file nobody edits again never healed, because healing only ran for changed or deleted paths. Two notes on it:
The migration's tests are in Three things a review round of my own turned upI put this branch through an adversarial pass before replying, and it found ways the first version of the migration could have deleted live rows. All are fixed, all are now regression tests verified to fail against the corresponding mutation, and I would rather hand you the findings than have you find them. The first was a data-loss path. A The second was a near miss. The Go externals lane mints live The third was the sticky verdict: the repository classification is drawn from rows eviction never removes, so a store carried across platforms keeps a stale verdict. I first bounded that with a fail-safe and then, after a fourth pass produced a reproducible counterexample, replaced the whole path-shape argument with the ownership test described above. Two cost fixes came out of the same pass: the scope query reads only file nodes, which is both the honest test and roughly five times cheaper because it then rides a covering index instead of fetching every row of the nodes table; and the node-keyed cleanup is skipped entirely when nothing is doomed, since this step runs before I am flagging the review history rather than presenting a clean result because it is the honest summary of how much a destructive migration on someone else's store deserves. Four passes, four findings, three of them mine. P2: the builder testsYou are right, and the reason generalizes further than the tests. The call the fix removed is What the six tests can do, and now do, is pin a byte-identity contract that holds on every platform: the builder returns the caller's spelling verbatim. Any future unconditional re-spelling - The healing logic is the part that genuinely does not care what platform it runs on, and that is where the coverage now sits. Those tests seed One empirical checkI ran the migration's own predicates read-only against my production store before asking you to take it on faith: 4.3 GB, Windows-written, four indexed repositories, roughly 302k nodes and 2.77M edges. The guard fires there, so this is the real path and not the no-op one. It removes 1,088 legacy artifact nodes and 1,088 legacy coverage edges, and leaves roughly 13,500 native-spelled coverage edges untouched. Nothing in the license / team / module population ends up orphaned, so no shared target is removed at all. No node it removes is also a live file node. The ownership test separates the two populations cleanly there: of those 1,088 rows, zero have their path in Of the sidecars, only Verification
|
On Windows, editing an indexed file leaves its old todo node and annotated
edge in the store: the todo keeps the pre-edit tag and text forever, and a
removed marker never disappears. I hit this live when a counted acceptance
cell in my fixture repo went one-over on annotated edges after an edit, and
the store showed the stale row surviving an incremental patch.
Root cause is a path-spelling split inside one file's own artifacts. The
ingest pipeline keys file identity by the exact relPath spelling the indexer
uses - OS-native separators for subdirectory files on Windows - and both
removal lanes (the structural replace commit and deleted-file eviction) match
nodes by that spelling and remove edges by evicted-endpoint touch. The
coverage-domain builders (todos, licenses, codeowners, codegen, fixtures,
modules) re-spelled the caller's relPath with filepath.ToSlash before minting
node IDs, FilePath fields, and edge endpoints. Nothing downstream ever
matches that spelling again on Windows, so:
and deletion of their file (the stale-tag repro above);
it merges with the file identity, duplicates it instead;
subdirectory files reference a file-node ID spelling that does not exist,
so they dangle from birth (the modules lane even mints its synthetic
manifest node natively two lines below the re-spelled edge).
Store evidence from the live repro repo (my counted acceptance fixtures):
2,323 native-spelled edges vs exactly 1 forward-slash edge - the one stale
annotated edge the incremental patch left behind.
Fix, first commit: the six builders preserve the caller's spelling verbatim,
so coverage artifacts share extraction identity with everything else and ride
the normal replace/evict machinery by construction. POSIX behavior is
byte-identical (ToSlash is the identity there). Each builder carries a
contract comment, and a spelling-preservation test per package pins the
contract with a platform-native path.
Fix, second commit: healing for stores written before the first commit. Both
removal lanes funnel through evictFilesBatched, so it now also evicts the
forward-slash twin of any path whose spelling differs. Only pre-fix coverage
rows can match the twin (nothing else ever minted a re-spelled FilePath), and
on POSIX the twin equals the native path and the sweep adds nothing. A stale
row therefore heals on its file's next edit or deletion - exactly the moments
its content could have changed; untouched files keep serving their
still-accurate rows. Two end-to-end regressions seed a pre-fix-spelled todo
row and prove both lanes sweep it (and that the fresh extraction's
native-spelled todo lands in its place).
Residue this deliberately does not chase: pre-fix licensed_as / owns /
generated_by / depends_on_module edges whose only anchor is the re-spelled
file endpoint. They are endpoint-dangling, so no node-anchored traversal can
reach them; they only inflate raw edge counts until a store rebuild. Sweeping
them would need an edge-by-file_path eviction primitive on both backends,
which did not seem worth the surface for invisible rows - happy to add it if
you disagree.
One sibling I noticed but left out of scope: the dotnet project extractor's
cross-file ProjectReference endpoints are forward-slashed by design so both
sides agree on an ID, but the referenced project's own file node is native-
spelled, so the agreement fails on Windows for subdirectory projects. That is
a cross-file ID-agreement question (which side normalizes), not a staleness
bug, so I can file it separately if useful.
Full race suite on Windows: fail-name set matches my baseline except two
spinner-glyph tests that fail identically on a clean checkout of the merge
base (upstream drift, unrelated), and eight baseline names that now pass.
The six builder packages and the indexer package are clean.