I039: OSS corpus test system + the four defects it found (v0.9.0, schema v25) #58

Merged
buildagent merged 12 commits from corpus-test-system into master 2026-08-03 14:06:57 +02:00
Member

Closes #39, #40, #43, #44, #54, #55, #56, #57. Advances #41, #42, #45. #53 stays open — see below.

13 commits, 24 files, +5,485 lines. Version 0.8.7 → 0.9.0; schema v21 → v25.

What this is

Seven real OSS repositories pinned by sha, graded by oracle-free invariants, self-oracling mutations, a live-watcher history replay, and an exact-count ratchet. Plus the four resolver defects the corpus found and three of them fixed.

The spike falsified four of its own predictions first

Before building anything, I measured the plan's premises against the 7 repos at v0.8.7:

prediction result
"will find crashes/parse errors day one" FALSE — 7/7 clean, parse_errors=0
determinism may drift already clean — 7/7 identical
cold == incremental may drift already clean — 4/4
deletion mutation will find phantoms 0 rebinds / 1413 refs

The indexer path at tier-1 scale had nothing left to say, so the plan's early phases were demoted and the watcher and scale promoted. Every defect below came from those two axes.

Defects found and fixed

#52 sticky anchored resolution. Tier 1Q anchors qualified refs on any code file's stem — third-party evidence a stem-blind refill can't see, made permanent by target_id IS NULL gating. Fixed with stale_path_evidence + an instr() relevance test that NULLs matching resolutions and forces a Full pass; the two halves are inseparable, because NULLing under a Scoped pass loses the resolution permanently.

#53/#54 superlinear resolve cost. Not algorithmic — three missing indexes (one of them temp.symbol_buckets' name-join, I025's failure mode recurring) plus a correlated enclosing-symbol subquery in rebuild_symbol_edges, now set-wise: 5.79s → 1.35s, edge set byte-identical on all 9 repos.

#56 CommonJS invisibility. express: 410 require() calls, zero imports — its whole module graph missing. Both halves shipped: export-surface analysis (precision) and import capture (0 → 388 imports).

#57 relative specifiers matched by bare stem. ./utils from lib/ reached test/utils.js. Now path-resolved; 155/155, 509/509, 173/176 specifiers resolve.

Things that went other than planned — please read these

  1. My filed fix for #52 would have caused permanent recall loss. Reproduced before writing code.
  2. #56 part 2 was landed, measured, reverted, and re-landed. With stem-matching underneath it produced 6 phantoms; #57 removed the cause.
  3. #53's acceptance is NOT met. Superlinearity is reduced (exponent 1.86 → 1.55), not removed; tier spread is still 3.1–7.9×. Not closing it on a good-looking speedup.
  4. My #46 upside estimate was wrong — import capture gained exactly 0 resolutions. The dominant CommonJS shape needs receiver typing, not an import edge. The value is the module graph.
  5. #42's split invariance was dropped, not built — no sharp invariant exists, and a test around a vague property is the vacuity trap.
  6. The cross-language matrix is 3 languages, not 6 — TS/Ruby/C# fixtures resolved through self-healing tiers, so including them would assert on a shape that isn't present.

Adversarial review: 8 findings, all in the tests or the claims

Neither core fix could be broken (1,036,264 ref rows compared with zero differences; injected mid-transaction errors; an 8-round two-process race; 8 mutants). What the reviewers did break:

  • write_delete's arm had zero isolated coverage — deletable with the suite green;
  • corpus_cold_equals_incremental_add was vacuous — passed with the entire fix disabled;
  • a 1000× regression (17ms → 17.9s to delete one file);
  • code↔text reclassification unrecorded in both directions;
  • migration ordering costing ~21s of avoidable upgrade time;
  • two overclaimed speedups and one unmet criterion.

Verification

cargo test --workspace green · fmt · clippy -D warnings · all 7 precision_gate languages (phantom == 0) · corpus metamorphic 6/6 · mutation guard 0 rebinds / 6,981 sites · ratchet green · scale within ceilings · watcher replay 18 converged / 3 inert, all divergences exactly the new-directory commits.

Live upgrade dogfooded before tagging: this repo's own v21 index migrated to v25 in 5s (full reparse, 0/269 parse errors), and the migrated DB is byte-identical to a cold index on symbols, refs and edges. doctor clean. That path is the I034 failure class and had never run against a real in-use database.

Reviewer notes

  • m0025 invalidates stat+hash, so the first run after upgrading re-parses every file. ~5s on this repo; budget more on a large one.
  • The corpus and corpus-scale CI jobs are nightly/weekly, not PR gates — they need GitHub egress from the runner, which is unverified. I'll dispatch them manually against this branch; if egress is blocked that's the thing to fix before merge.
  • tests/corpus/baseline.json carries a mandatory _blessed.reason; every count change in this PR has one.
Closes #39, #40, #43, #44, #54, #55, #56, #57. Advances #41, #42, #45. **#53 stays open** — see below. 13 commits, 24 files, +5,485 lines. Version 0.8.7 → **0.9.0**; schema **v21 → v25**. ## What this is Seven real OSS repositories pinned by sha, graded by oracle-free invariants, self-oracling mutations, a live-watcher history replay, and an exact-count ratchet. Plus the four resolver defects the corpus found and three of them fixed. ## The spike falsified four of its own predictions first Before building anything, I measured the plan's premises against the 7 repos at v0.8.7: | prediction | result | |---|---| | "will find crashes/parse errors day one" | **FALSE** — 7/7 clean, `parse_errors=0` | | determinism may drift | **already clean** — 7/7 identical | | cold == incremental may drift | **already clean** — 4/4 | | deletion mutation will find phantoms | **0 rebinds / 1413 refs** | The indexer path at tier-1 scale had nothing left to say, so the plan's early phases were demoted and the **watcher** and **scale** promoted. Every defect below came from those two axes. ## Defects found and fixed **#52 sticky anchored resolution.** Tier 1Q anchors qualified refs on any code file's *stem* — third-party evidence a stem-blind refill can't see, made permanent by `target_id IS NULL` gating. Fixed with `stale_path_evidence` + an `instr()` relevance test that NULLs matching resolutions **and forces a Full pass**; the two halves are inseparable, because NULLing under a Scoped pass loses the resolution *permanently*. **#53/#54 superlinear resolve cost.** Not algorithmic — three missing indexes (one of them `temp.symbol_buckets`' name-join, **I025's failure mode recurring**) plus a correlated enclosing-symbol subquery in `rebuild_symbol_edges`, now set-wise: **5.79s → 1.35s**, edge set byte-identical on all 9 repos. **#56 CommonJS invisibility.** express: 410 `require()` calls, **zero** imports — its whole module graph missing. Both halves shipped: export-surface analysis (precision) and import capture (0 → 388 imports). **#57 relative specifiers matched by bare stem.** `./utils` from `lib/` reached `test/utils.js`. Now path-resolved; 155/155, 509/509, 173/176 specifiers resolve. ## Things that went other than planned — please read these 1. **My filed fix for #52 would have caused permanent recall loss.** Reproduced before writing code. 2. **#56 part 2 was landed, measured, reverted, and re-landed.** With stem-matching underneath it produced 6 phantoms; #57 removed the cause. 3. **#53's acceptance is NOT met.** Superlinearity is reduced (exponent 1.86 → 1.55), not removed; tier spread is still 3.1–7.9×. Not closing it on a good-looking speedup. 4. **My #46 upside estimate was wrong** — import capture gained exactly 0 resolutions. The dominant CommonJS shape needs receiver typing, not an import edge. The value is the module graph. 5. **#42's split invariance was dropped, not built** — no sharp invariant exists, and a test around a vague property is the vacuity trap. 6. **The cross-language matrix is 3 languages, not 6** — TS/Ruby/C# fixtures resolved through self-healing tiers, so including them would assert on a shape that isn't present. ## Adversarial review: 8 findings, all in the tests or the claims Neither core fix could be broken (1,036,264 ref rows compared with zero differences; injected mid-transaction errors; an 8-round two-process race; 8 mutants). What the reviewers *did* break: - `write_delete`'s arm had **zero** isolated coverage — deletable with the suite green; - `corpus_cold_equals_incremental_add` was **vacuous** — passed with the entire fix disabled; - a **1000× regression** (17ms → 17.9s to delete one file); - code↔text reclassification unrecorded in both directions; - migration ordering costing ~21s of avoidable upgrade time; - two overclaimed speedups and one unmet criterion. ## Verification `cargo test --workspace` green · fmt · `clippy -D warnings` · all 7 `precision_gate` languages (**phantom == 0**) · corpus metamorphic 6/6 · mutation guard **0 rebinds / 6,981 sites** · ratchet green · scale within ceilings · watcher replay 18 converged / 3 inert, all divergences exactly the new-directory commits. **Live upgrade dogfooded before tagging**: this repo's own v21 index migrated to v25 in 5s (full reparse, 0/269 parse errors), and the migrated DB is **byte-identical to a cold index on symbols, refs and edges**. `doctor` clean. That path is the I034 failure class and had never run against a real in-use database. ## Reviewer notes - **m0025 invalidates stat+hash**, so the first run after upgrading re-parses every file. ~5s on this repo; budget more on a large one. - The **`corpus` and `corpus-scale` CI jobs are nightly/weekly, not PR gates** — they need GitHub egress from the runner, which is *unverified*. I'll dispatch them manually against this branch; if egress is blocked that's the thing to fix before merge. - `tests/corpus/baseline.json` carries a mandatory `_blessed.reason`; every count change in this PR has one.
Seven real open-source repositories, one per language, pinned by commit
sha in tests/corpus/corpus.toml, graded by oracle-free invariants and
self-oracling mutations. Unlike tests/fixtures nothing here is
hand-authored, so the corpus reaches ambiguity the 4-14 file fixtures
cannot: 7056 cross-file bindings under test vs candidate pools of ~1.

A spike ran first and FALSIFIED four predictions: parse/crash (7/7
clean), determinism (7/7), cold==incremental (4/4) and deletion
mutation (0 rebinds / 1413 refs) were already clean at v0.8.7. The
indexer path had nothing left to say, so the watcher — where I028,
I034 and the open Linux re-watch issue all lived — was promoted.

  #40/#44 harness: sha-pinned fetch, id-independent projection
          (path#name@line, never target_id), coverage manifest,
          positive controls enforced by Coverage::finish
  #42     metamorphic: determinism 7/7, path-invariance 7/7,
          cold==incremental split per mutation kind
  #43     mutation guard: 0 rebinds, 0 stray bindings / 7056 sites
  #39     watcher replay: live path (safety net OFF) vs the shipped
          configuration, over real ripgrep history

Found #52: resolution is sticky. Every resolve tier is gated on
`target_id IS NULL` and the only un-resolver keys on symbol NAMES, so
renaming a file destroys the stem-derived qualifier-anchoring evidence
without invalidating anything. Coherence defect, 0 dangling targets,
NOT fixed here — resolver changes need adversarial review. The three
affected sites are enumerated with a written reason; anything new
still fails.

Quantifies the open Linux gap: with reconcile_interval None, ALL live
divergences were new-directory commits and nothing else; the periodic
reconcile converges exactly those.

Corpus is opt-in via COSI_CORPUS_DIR (no default path — it would
silently slow every cargo test). Nightly CI job sets
COSI_CORPUS_REQUIRE=1 so a run that grades nothing FAILS.

Not covered: permutation and split invariance (#42), tier-3 scale
(#41), Windows replay, rename/dir-delete event shapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
The second axis the corpus spike could not reach. Everything it
measured was 170-580 files; peak RSS had no ceiling asserted anywhere
and the existing bench_* tests benchmark 4-14 file fixtures.

Two tier-3 repos, permissive and sha-pinned. Measured at v0.8.7:

  rust-analyzer  1778 files / 340676 refs /  80397 edges / 107 MB / 77s
  py-django      4235 files / 460978 refs /  52615 edges / 145 MB / 99s

Hard gates: no panic, no hang, non-empty index, wall clock < 900s,
peak RSS < 2 GiB. Recorded but NOT gated: wall clock, RSS, DB bytes,
counts and query p50/p99 -> target/corpus/scale-<repo>.json, seeding
the #45 ratchet. Thresholds there would encode this machine's speed.

Findings:

  * Peak RSS is 232-300 MiB — an order of magnitude under the 8 GiB I
    first guessed, so the ceiling was tightened to 2 GiB where it can
    actually catch something.
  * Query latency holds at scale: find_callers-shaped p99 <= 321us on
    a 341k-ref graph, FTS trigram p99 <= 4.3ms.
  * Cold-index throughput does NOT hold: 4.4k refs/s vs ~24k at
    tier-1, i.e. time ~ refs^1.7. Filed as #53.
  * The 1M-edge cap is NOT exercised (80k and 53k edges) and the cap
    lives daemon-side anyway. The test reports where each repo sits
    relative to it rather than pretending to cover it.

fetch.sh gains COSI_CORPUS_TIERS so the nightly tier-1 job stops
cloning multi-thousand-file repos into an ephemeral container; the
tier-3 suite runs in a separate weekly corpus-scale job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
## #52 — resolution no longer survives its own evidence

Tier 1Q anchors qualified refs on `temp.qual_known`, which reads any
code file's STEM or package tail — evidence from a THIRD file, neither
the ref's nor the target's. A stem is not a symbol name, so the
stale-name refill never saw it change, and every tier is
`target_id IS NULL`-gated, making the old decision permanent.

Three things the original issue got wrong, all corrected by
investigation + independent reproduction:

  * NOT C#-specific — reproduces wherever anchor, target and ref are
    three different files;
  * the anchor file needs NO SYMBOLS (file_keys gets a stem row for
    every code file), so renaming one scheduled nothing and the batch
    took the Scoped pass — or was Skipped entirely;
  * the divergence runs BOTH ways. Adding a symbol-less anchor leaves
    incremental BEHIND cold. Invalidation alone cannot fix that.

New `stale_path_evidence` records the anchor keys a code file
contributes, written whenever a path joins or leaves the code-file set
(both directions, including code<->text reclassification via
write_stat_touch). `apply_resolution` tests relevance with
`instr(qualifier, key) > 0` — a strict superset of every qual_known
arm, so it cannot miss a case without re-implementing segmentation —
then NULLs the matching `qualified = 1` rows AND forces Full.

The two halves are inseparable by design: forcing Full is what makes
the UPDATE safe. NULLing under a Scoped pass loses the resolution
PERMANENTLY (Scoped only revisits refs in scope or matching a scope
file's symbol name; later reconciles then return Skipped forever) —
strictly worse than the bug. That failure mode was reproduced before
any code was written.

## #53 — the scaling was three missing indexes, not algorithmic cost

  * symbols had no index on parent_id: 121,234,431 rows visited to
    emit 6,747 on rust-analyzer, 37% of a 70s resolve;
  * temp.symbol_buckets' WITHOUT ROWID PK made tier 3's (name, lang)
    join a cross product — 66s of 96s on django. This is I025's
    failure mode recurring;
  * temp.file_keys / file_pkg had no indexes at all.

Isolated effect: rust-analyzer 64.1s -> 24.6s (2.6x), django
87.2s -> 24.6s (3.5x); Scoped passes, what a watcher runs, 2.4x and
10.9x. Output verified byte-identical across 9 corpus repos —
1,036,264 ref rows, zero differing. No ANALYZE (measured 2.8x worse),
now pinned by a test.

NOT claimed: "within 2x between tiers". The spread is still 3.1-7.9x
and the log-log exponent only falls 1.86 -> 1.55. Superlinearity is
reduced, not removed; #53 stays open.

## Adversarial review — 8 findings, all fixed

Two reviewers could not break either core fix (injected mid-pass
errors, two-process races, full projection diffs, 8 mutants). What
they did break was my test suite:

  * write_delete had ZERO coverage — deletable with the suite green;
  * corpus_cold_equals_incremental_add was VACUOUS: its probe stem was
    picked so no source referenced it, which structurally cannot make
    a qualifier root-plausible. It passed with the whole fix disabled.
    Now models the parent-anchor arm and VERIFIES by throwaway cold
    index that the projection moves;
  * a 1000x regression (17ms -> 17.9s to delete one symbol-less file)
    fixed by the relevance test above;
  * corpus_watcher_replay decided inertness by sampling the live DB
    after a ~1s cold index, by which time a healthy watcher had
    converged — real work counted as inert. Deriving it from the tree
    took the suite from 2 converged/19 inert to 18/3.

Migrations m0023 (index) then m0024 (path-evidence table + re-heal);
that order halves the one-time upgrade (46.7s -> 25.3s). The re-heal
NULLs everything, not the fix's own predicate, so it asserts agreement
with a cold index rather than with itself.

known_incoherence is now EMPTY and the corpus rename suite runs with
tolerate_known: false.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
The manifest and corpus_scale's doc table still carried pre-#53
numbers (~82s / ~112s). Post-index the same repos index in ~25-31s.

Recorded as a RANGE, not a point: the same binary produced 25s on a
quiet box and 84s while sharing it with two other suites, which is
exactly why wall clock is a generous ceiling here and a ratcheted
metric in #45. Counts are exact and unchanged, which is the part that
matters — the indexes eliminated scanning, not candidate evaluation.

Also notes the index's measured cost (~0.9% DB bytes, ~2.6% peak RSS),
both recorded and neither gated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
Every other corpus suite asks "is this index self-consistent?" —
deterministic, coherent with a cold index, free of phantom rebinds.
None notices a change that is self-consistent but unintended: a
resolver tweak that quietly moves thousands of refs passes all of
them. #52 is the worked example — it changed resolution on real repos
and no existing test could see it.

tests/corpus/baseline.json pins exact structural counts per pinned
repo (files, symbols, refs, resolved, edges, imports);
corpus_ratchet.rs fails on any drift and reports it dimension by
dimension with the delta.

ONLY counts are gated, deliberately. They are deterministic — proven
by corpus_determinism — so exact equality is legitimate. Wall clock is
not: the same binary indexed rust-analyzer in 25s quiet and 84s under
load, so a timing ratchet would encode the runner's speed. Timings
stay recorded-only in target/corpus/scale-*.json. (That same hazard
just bit resolver.rs's 10s bounded-work assertion during this change;
filed as #55.)

Blessing requires COSI_CORPUS_BLESS_REASON and writes it into the
committed baseline, so an intentional change is visible in review
rather than a silent green-wash — I030b shipped a metric that "passed"
because a derived value was mutated rather than measured.

Verified non-vacuous, not assumed: a one-ref perturbation of the
baseline fails with `rust-ripgrep: resolved 13760 -> 13759 (-1)`,
restoring passes, and a reasonless bless is refused.

Records js-express imports=0 as a known oddity rather than hiding it:
express is CommonJS and the javascript plugin does not capture
require(). Adjacent to #31 item 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
The two bounded-work tests asserted `elapsed < 10s`. That conflates
"the resolver regressed" with "the box was busy": during the #45 work
`qualified_generated_names_resolve_with_bounded_work` failed at load
~45 and then passed 3/3 in isolation on the identical binary.

Now each test indexes a SMALL and a FULL workload back-to-back and
bounds the RATIO. Machine speed cancels — both runs are slowed by the
same factor — while the blow-ups these guard (I020's correlated
candidate scan, I025's 109s cross product) are super-linear and
inflate the ratio regardless of host speed.

The small run is the noisy denominator at ~0.1s, so it is a median of
three; that took the qualified ratio's spread from 4.8 (18.5-23.3) to
1.9 (19.2-21.1) against a 30x ceiling. A 300s absolute bound remains
purely as a hang detector.

Verified rather than assumed, and one result was NEGATIVE:

  * the assertion is live — forcing the ceiling below the observed
    ratio fails both tests with a readable message;
  * it does NOT catch #53's bucket cross product. Disabling
    temp.ix_symbol_buckets_name leaves the ratio unchanged (21.2x vs a
    19-21x baseline) because this synthetic shape is only ~40k refs
    and that regression needs tier-3 pool sizes. The old 10s bound
    missed it too (2.35s), so coverage is not lost — but the doc now
    says so plainly and points at corpus_scale + corpus_ratchet as the
    real guard, instead of leaving the test looking stronger than it
    is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
`from_id` was a CORRELATED subquery — "smallest symbol covering this
line", `ORDER BY ... LIMIT 1` — evaluated once per resolved ref. The
query plan showed `USE TEMP B-TREE FOR ORDER BY` *inside* it: one sort
per ref, 116k of them on rust-analyzer. Cost 0.27s at 36k refs vs
5.79s at 340k (24.6x for 9.3x refs).

Now the enclosing symbol is resolved once per distinct (file_id, line)
— 89k rather than 116k probes on rust-analyzer — via a single
window-function sort into an indexed temp table.

Measured on a quiet box, medians of 3:
  isolated SQL   5.79s -> 1.35s  (4.3x)
  end-to-end     rust-analyzer 25.7s -> 21.4s wall

Output equality PROVEN, not inferred from counts: symmetric difference
of the full edge set (from_id, to_id, weight) between old and new SQL
is ZERO on all nine corpus repos; end-to-end, the fully-rendered edge
projection (both endpoints as path#name@line plus weight) is md5-
identical on rust-analyzer, py-django, rust-ripgrep, cs-dapper, ts-zod.

Two things deliberately NOT done, both measured:

  * NOT a single self-contained statement. Expressing it with
    MATERIALIZED CTEs measured 19.9s — 3.4x SLOWER than the correlated
    form it replaces — because an unindexed CTE join is probed once per
    ref, the very shape that made the original slow. The temp index is
    load-bearing; re-measure before folding this back for tidiness.
  * m0013's inline copy is left untouched. Migrations are frozen
    history, it is a one-time seed, and runtime rebuilds overwrite it
    from this function — with provably identical output.

Since the SQL is no longer expressible as one SELECT, the exported
SYMBOL_EDGES_SELECT const is gone and the daemon's test helper now
calls the production `rebuild_symbol_edges` directly. One
implementation, so the two cannot drift — the same reasoning behind
sharing TEST_MODULE_CONTAINS_REF.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
A module-level `var`/`function` in a non-ESM file was `Unknown`
because `module.exports` went unanalyzed — and `Unknown` PASSES the
resolver's cross-file visibility gate. So a module-PRIVATE symbol was
an eligible cross-file candidate for any same-named reference
anywhere in the project.

`collect_cjs_exports` now reads the file's export surface and marks
module-level symbols `Exported` or `File` accordingly. A file with NO
detectable exports keeps the old conservative `Unknown` — global
scripts and re-export idioms we do not model must not lose real
resolutions (pinned by `bare_cjs_without_exports_stays_unknown`).

Measured on the pinned express repo: resolved 4154 -> 4152. Both
removed bindings are VERIFIED phantoms — a local `id` in
examples/route-middleware and test/app.router.js binding to
examples/mvc/controllers/user/index.js's private `id`. Zero
resolutions gained, so this is precision with no recall cost.

One subtlety that cost a wrong result before I caught it: the export
set must contain only names another file can refer to a symbol BY, not
every value that escapes. express writes `exports.request = req`;
collecting the right-hand identifier made the private `req` Exported
again and resurrected the very phantom class this removes. Keys only.

Two existing tests asserted the old contract and are deliberately
flipped with the reasoning inline: `js_cjs_export_is_now_recognised`
and `cjs_exports_are_analyzed_and_privates_are_file_scoped`.
`Unknown` and `Exported` are treated alike by the gate, so that
direction changes nothing; the win is entirely in the File direction.

## NOT included: require() import capture (#56 part 2)

I implemented it (all binding forms, correctly skipping computed and
interpolated specifiers) and REVERTED it, because measuring showed it
is not shippable yet:

  imports 0 -> 388 on express, resolved +6/-2 — and all SIX gained are
  PHANTOMS. `lib/application.js:221` binds a local `fn` into
  `test/utils.js#fn`.

Root cause is upstream of the capture: `require("./utils")` from
`lib/` stem-matches BOTH `lib/utils.js` and `test/utils.js`, because
tier 1b's file-key arm compares stems and ignores relative-path
semantics. Landing the import rows widens reachability through that
hole. Shipping it would add 4 net phantoms to the guarantee that is
this product's core claim.

Filed with the evidence; the capture waits on the path-resolution fix.
m0025 (the extraction re-heal) is likewise deferred — this half
changes visibility only, and a resolve-only heal is not required
because `Unknown -> Exported` is gate-equivalent while
`Unknown -> File` only ever REMOVES candidates, which the next resolve
recomputes from scratch.

Baseline re-blessed with the reason recorded in the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
robust to parallel test execution

Two corrections to work committed minutes ago, both found by running
the gates rather than by reasoning.

## m0025 IS required (my previous commit message said it was deferred)

Visibility is recorded at PARSE time, so an existing database keeps
its stale `unknown` rows until every file is re-parsed. A resolve-only
heal would re-resolve against the stale values and converge on the OLD
answer. So this is the m0021/m0022 extraction pattern — stat+hash
invalidated — and the migration's doc now says exactly that instead of
describing the reverted import-capture half.

Pinned by `m0025_invalidates_stat_and_hash_so_visibility_is_re_extracted`,
which seeds a v24 DB with an `unknown` symbol and asserts BOTH tiers
are invalidated. Without that assertion the migration would look
correct while converging on nothing.

## The #55 ratio assertion was still load-sensitive

It failed inside `cargo test --workspace` at load ~40, then passed 3/3
in isolation on the same binary — the exact symptom #55 was filed for,
reintroduced by my own fix.

Cause: I sampled the small run three times and THEN the big run once.
cargo runs targets in parallel, so a neighbouring test starting
between those phases changes the CPU available to only one of them,
skewing the ratio.

Now three INTERLEAVED (small, big) pairs, taking the MINIMUM ratio.
Each pair sees roughly one machine state, and contention can only
inflate a ratio — never deflate it — so the minimum converges on the
true scaling factor while a genuine super-linear regression inflates
every observation and cannot hide in it.

Verified where it actually matters: green under the full parallel
`cargo test --workspace` run that failed before, plus in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
Tier 1b's and tier 3's reachability arms matched an import against a
candidate file's bare STEM, which discards the directory a RELATIVE
specifier is relative to. Measured on express: `require("./utils")` in
`lib/application.js` matched both `lib/utils.js` and `test/utils.js`.

Relative specifiers are now resolved to an actual file (in Rust —
SQLite cannot normalise `../`) and match only through the resolved
`temp.import_rel` table; non-relative specifiers keep the stem/segment
behaviour, having no directory to resolve against. A strict narrowing:
it can remove candidates, never invent one.

Tier 1R is deliberately NOT narrowed. Its arm additionally requires the
BOUND TYPE to appear in the module string, which a relative specifier
never satisfies (the type is on the import's alias) — so it cannot
produce the same-stem confusion, and excluding relative specifiers
there would cost recall for no precision gain.

## Two dialects, both found by measuring rather than reasoning

The first cut resolved paths naively and cost ts-zod 2 659 resolutions
— a 23% recall collapse. Cause: TypeScript NodeNext writes the EMITTED
extension, `import "./api.js"` where the file is `api.ts`, so nothing
resolved and the narrowing removed reachability wholesale. Fixed with
extension substitution.

Then python-flask resolved only 14 of 176 specifiers (8%). Cause: the
python plugin stores the IMPORTED SYMBOL in the module string, so
`from ..config import Config` arrives as `..config.Config` and the last
segment is a symbol, not a path component. Both readings are now
offered, longest first. 173/176 (98%).

That resolution RATE is the metric that says whether the narrowing is
sound, so it is now logged (`relative_imports=N resolved=M`): an
unresolved specifier contributes no reachability at all, so a low rate
means lost recall rather than removed phantoms.

## Measured effect

  ts-zod        11246 -> 10931   (gained 125, lost 440)
  python-flask   2075 ->  2076   (gained 3,   lost 2)
  js-express     4152 ->  4152   unchanged
  php-guzzle / ruby-sinatra / rust-ripgrep / cs-dapper  unchanged

ts-zod's 440 losses are stem-coincidence phantoms, not resolution
failures — all 509 of its relative specifiers resolve to a file. Of the
losses, 81 crossed the v3/v4 version boundary and 20 bound into
test/bench/fixture directories; a hand-traced case had `init` in
`mini/iso.ts` bound to `core/core.ts` purely because the specifier
`../core/index.js` contains the segment `core`, and `core.ts` does not
export `init` at all. The 125 gains cross no version boundary.

Three hermetic tests, each with a positive control: the same-stem decoy
(with a control call into the file the specifier really names, since an
import binding alone lands in `imports` not `refs` and would make the
control vacuous), NodeNext substitution, and the Python symbol-suffix
form.

Baseline re-blessed with the reasoning recorded in the file; the
ratchet caught all four count changes first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
The JS/TS plugin emitted imports only from `import_statement`, so
`require()` — a plain `call_expression` — was invisible. express is
100% CommonJS: 410 `require()` calls across 131 files and ZERO
imports, i.e. its entire module graph was missing, starving every
import-dependent resolution path.

Now captured in the SAME row shape as ESM (one row per in-scope
binding with `alias`, or a module-only row for a side-effect
`require`), covering plain bindings, shorthand and renamed
destructuring, array patterns, `require("m").member`, bare statements,
dynamic `import()`, and static template specifiers. A computed
`require(name)` or an interpolated template is deliberately NOT
captured: there is no statically-known module and inventing one is the
same dishonesty as inventing a resolution.

js-express imports 0 -> 388, ts-zod +1.

## Why this is a re-land

This was implemented, measured, and REVERTED a few commits ago because
it produced six phantoms — `lib/application.js` binding a local `fn`
into `test/utils.js#fn`. The import rows were correct; the fault was
downstream, in tier 1b matching `./utils` against any file whose STEM
is `utils`. #57 fixed that, and with it underneath the same capture now
lands with:

  imports 0 -> 388,  resolved 4154 -> 4152,  gained=0,  lost=2
  lib/* -> test/utils.js bindings: 0   (was 6)

The two losses are #56 part 1's already-blessed phantom removals, not
new. Zero resolutions gained — which also corrects my original estimate
on the issue: the dominant CommonJS shape is `util.method()`, a member
call through a module object, and that needs receiver typing rather
than an import edge. The value here is the module graph itself
(get_dependencies, importers_of, change_impact), not a resolution jump.

One bug found by measuring the resolution RATE rather than assuming it:
express's `require("../..")` walks to the repo root, leaving an empty
base, and the directory-entry candidate builder produced "/index.js" —
an absolute path matching nothing. Fixed; relative-specifier resolution
is now 155/155 on express, 509/509 on zod, 173/176 on flask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
release: v0.9.0 — OSS corpus test system + resolver fixes (I039)
All checks were successful
CI / cargo fmt (pull_request) Successful in 17s
CI / OSS corpus (nightly) (pull_request) Has been skipped
CI / OSS corpus tier-3 scale (weekly) (pull_request) Has been skipped
CI / cargo clippy (pull_request) Successful in 1m50s
CI / cargo check (MSRV 1.88) (pull_request) Successful in 3m57s
CI / cargo check (windows-gnu) (pull_request) Successful in 4m7s
CI / cargo deny (pull_request) Successful in 4m34s
CI / cargo test (pull_request) Successful in 3m0s
29b4c623a4
Version 0.8.7 -> 0.9.0. Schema v21 -> v25.

A minor bump rather than a patch: four schema versions, two resolution
behaviour changes (#52 anchored-resolution coherence, #57 relative
specifier resolution), and a visibility contract change for CommonJS
files. Existing databases converge via m0023-m0025; m0025 invalidates
stat+hash, so the first run after upgrading re-parses.

Closure record at _prdoc/records/I039-oss-corpus-and-resolver-fixes-closure.md,
including the eight adaptations where the work turned out other than
planned — among them that my own filed fix for #52 would have caused
permanent recall loss, that #53's acceptance is NOT met and the issue
stays open, and that #42's split invariance was dropped rather than
faked because no sharp invariant exists.

LIVE UPGRADE DOGFOODED before tagging: this repo's own v21 index
migrated to v25 in 5s with a full reparse and 0 parse errors, and the
migrated database is byte-identical to a cold index on symbols, refs
AND edges. `doctor` reports schema 25, integrity ok, 0/269 parse
errors. That path had never been executed against a real in-use
database and is the I034 failure class, so it was the first thing
verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KExm1ysuBrhWPytdSrnUKd
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
h-dv/code-index!58
No description provided.