Skip to content

feat(probe-pin): probe-manifest runner that pins comment claims to measured evidence - #7315

Merged
matthewevans merged 15 commits into
phase-rs:mainfrom
lgray:tool/probe-pin
Aug 13, 2026
Merged

feat(probe-pin): probe-manifest runner that pins comment claims to measured evidence#7315
matthewevans merged 15 commits into
phase-rs:mainfrom
lgray:tool/probe-pin

Conversation

@lgray

@lgray lgray commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

Adds probe-pin, a standalone workspace tool that turns behavioural prose in code comments into a transcription of measured evidence. It runs a TOML probe manifest — each probe bind-mounts a mutated copy of a source file over the real path inside an unprivileged mount namespace, runs a target test binary, reads libtest JSON records — and renders the resulting verdict table into a digest-stamped comment block that --check can re-verify for drift.

The tool eats its own dogfood: its verification matrix is executed by running probe-pin on itself, and cargo probe-pin check on its own manifest is part of the gate battery below.

Files changed

  • crates/probe-pin/ — new crate: manifest schema + validation, mutant materialization, namespace isolation, libtest-record verdicts, digest/render/splice/check, CLI (src/, 9 modules, 2,686 lines)
  • crates/probe-pin/tests/ — Tier-1 verification matrix and Tier-2 isolation suite (86 tests), plus fixture manifests
  • docs/probe-pin.md — usage, schema, isolation model, and the tool's stated non-guarantees
  • .cargo/config.toml — one alias line (probe-pin)
  • Tiltfileprobe-pin-check resource so --check has an executor, and probe-pin-e2e so the Tier-2 suite runs in the venue that has the capability CI lacks
  • Cargo.lock — purely additive

Root Cargo.toml is not edited: the workspace globs members = ["crates/*"] (Cargo.toml:5), so the crate joins automatically. No build.rs.

Track

Developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: /engine-implementer

Plan → review-engine-plan (3 rounds) → implement → review-impl → fix → review-impl → fix → review-implclass-closure pass. Where a pipeline leg is card-parser-specific (card-data projection, parse-diff), it is replaced by this tool's own gate battery — disclosed rather than skipped, because the crate contains zero engine or parser code.

The third review round is why the last commit exists, and the reason is worth stating rather than burying: rounds 1–3 each found instances of a single defect class — the tool renders a green row, or exits 0, for a run it never measured — and each round fixed the fields named while the next round found more in different fields. That pattern is evidence the class was not bounded by point review, so the remedy was a closure pass rather than a fourth round. See Scope Expansion.

CR references

None. probe-pin contains no game logic; a CR annotation here would be false provenance.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.
  • Gate A output below is for the current committed head.
  • Final review-impl below is stated for the current committed head.
  • Both anchors cite existing analogous code at the same seam.

All measured after the rebase onto upstream/main cc7668062, at head 8f2836349:

  • cargo fmt --all -- --checkexit 0
  • cargo clippy --workspace --all-targets -- -D warningsexit 0, zero warning lines
  • cargo test -p probe-pinexit 0: pure_logic 68 passed / 1 ignored, isolation_e2e 0 passed / 17 ignored, 0 failed
  • cargo test -p probe-pin --test isolation_e2e -- --ignoredexit 0: 17 passed, 0 failed (the Tier-2 suite actually executed, locally, where user namespaces work — proof the #[ignore] hides no failure)
  • cargo probe-pin check crates/probe-pin/tests/fixtures/dogfood.tomlexit 0 (the tool run against its own manifest)
  • git diff --numstat upstream/main..HEAD — 31 files, 7,792 insertions, 0 deletions, 0 binary files (positive control: a synthesized NUL file returns - -)

Ignored tests, disclosed in detail because in this crate an ignored test is exactly the defect the tool exists to catch. There are 18, of two entirely different kinds:

  • 1 in pure_logicppfixture_ignored, a probe target whose body must never run. It is the fixture that drives the execution-floor verification below, not a skipped assertion.
  • 17 in isolation_e2e — the whole Tier-2 suite, because CI cannot execute it. See CI Failures below; it is measured, not assumed, and the suite is verified green locally with -- --ignored.

Neither is a runtime capability check, deliberately: a test that silently no-ops when the namespace is missing reports as passed, which is this PR's own BLOCKER wearing a test harness. An ignored test reports as ignored.

Two-sided verification. Every row of the verification matrix has a DROP arm and a TRIVIALIZE arm, and each arm is stated in the test's own doc comment with what it must flip. A row whose DROP arm does not flip its assertion is vacuous — and in a tool whose thesis is that evidence must be measured rather than asserted, a vacuous test is self-refuting.

Scope of that claim, stated precisely rather than as one headline number: the full mutant matrix was executed against the rows that existed at the time, at the head where each was written. The rows added by the last two commits were each measured individually at their own head — every new guard reverted and its test observed to fail with the specific diagnostic it exists to produce, every mutated source restored and verified byte-identical with sha256sum -c. The matrix has not been re-run wholesale at the current head; cargo test -p probe-pin has, and is green.

Gate A

$ ./scripts/check-parser-combinators.sh "$(git rev-parse upstream/main)"
Gate G PASS (router/grant architecture: strict router vs permissive grant boundary intact)
Gate A PASS head=8f283634934b04b848122956d8211b8aa54df914 base=cc76680623e4d4628dc0291e88cc9c2d99bf32ff

The base is passed explicitly. The script defaults to git merge-base origin/main HEAD (:48), and origin here is a fork whose main lags; that default resolved to a different, shorter range. The run above is against the authoritative base.

Disclosure, because a green line here would otherwise be read as more than it is: scripts/check-parser-combinators.sh scopes to SCOPE='crates/engine/src/parser' (:51). This PR touches no file under that path, so the gate passes on an empty range. It is not-applicable-but-green, not evidence of combinator purity. The combinator mandate does not apply to this crate: its find is literal byte substitution, and the anchor lint is a denylist over user-authored manifest text, not parsing dispatch.

Anchored on

  • crates/engine-inventory-gen/Cargo.toml:2 and src/main.rs:1 — the workspace's existing standalone tooling-crate pattern: version.workspace/license.workspace/edition 2021 manifest shape, and a //! header naming the invocation that regenerates its output. probe-pin mirrors both.
  • Tiltfile:199 — the existing local_resource lint pattern (list-form cmd, separate CARGO_TARGET_DIR, narrow deps, TMP_IGNORE, auto_init, allow_parallel, labels). probe-pin-check follows it field for field, extending ignore because TMP_IGNORE is a filename glob (**/*.tmp.*) that does not match a tmp/ directory.

Honest scope note on Gate B: this is a new crate, so there is no prior art inside crates/probe-pin/. The two citations above are the nearest same-class analogs in the repository, and the plan traced the first end-to-end before adopting its conventions. Flagging rather than citing something closer-looking but unrelated.

Final review-impl

Round 3 was NOT clean — 1 BLOCKER, 3 MAJOR, 3 MINOR at head 08924b0ad, all reproduced against the built binary. Stating that plainly rather than reporting only the pass that followed.

Every one of the green-row findings was the same class. The remedy was therefore an authorized class-closure pass (final commit), not a fourth review round, and its acceptance gate was three measurements:

  • (a) each of the four execution surfaces refuses a hostile value, two-sided — hostile aborts naming the offending construct, benign still passes. Measured for argv, env, path and anchor.
  • (b) the execution floor fires on a zero-run — the BLOCKER's exact repro, flipped: an #[ignore]d pinned test now aborts instead of rendering a green row. Measured for both the #[ignore] and --bench routes, with the benign arm still passing.
  • (c) the filesystem-write producer census is complete — every producer proven to route through the containment guard or to be structurally outside manifest control, with the enumerating command published so the census is reproducible.

All three measured clean at the committed head, independently re-run by the driver with exit codes captured before any pipe.

Maintainer review — marker injection (resolved at head 9104cef64)

matthewevans blocked on: a generated block can inject a second marker. Two acceptable remedies were named; both are implemented, because measuring the first exposed a residual the second closes.

Manifest-derived fields were already refused at the head the review was written against, verified by re-running the four attack strings through the built binary — each aborts at exit 2 before any write: anchor = "\nPROBE-PIN:END" (the review's exact string) and anchor = "boom\nsecond line" abort naming the control character; marker = "PROBE\nPIN" and a projection sentence containing a marker tag abort naming the tag.

That coverage is field-shaped, and the same field-vs-surface reasoning that drove the class-closure pass applies here: two producers reach a rendered line without passing through a manifest field — an instrument's --version output, and the provenance paths lifted from the target's own panic text. Neither can be refused in advance. So block::splice now re-runs locate over its own spliced output and returns an abort rather than a block whose marker span the next check could not find. The property is unconditional at the write surface instead of conditional on field coverage.

Regression test write_can_never_produce_a_block_its_own_check_cannot_locate asserts both halves the review asked for: manifest::validate_block_text refuses the injected marker, and splice refuses to return an unlocatable block. Two-sided — deleting the postcondition line makes it fail at exit 101 naming the injected second END; the mutated source was restored and re-verified with sha256sum -c.

The census that premise rests on is now executable, not asserted. "Unconditional at the write surface" is only true while splice is the sole route to the pinned file's bytes. Measured: exactly two filesystem write producers exist in src/ — the scratch mutant, which is never a rendered block, and the pinned file, whose content argument is literally the splice(..)? call. The guard is inside the only write's argument, not merely on its path. every_write_of_the_pinned_file_routes_through_splice pins that, and a second fs::write of output.file now fails it rather than silently falsifying this paragraph.

Four arms, each flipping a different assertion: dropping splice from the write argument → exit 101 on the property; adding a third producer → exit 101 at found 3; breaking the search patterns → exit 101 on the zero-census positive control. The fourth arm did not flip, and the comment says so: the comment-strip's DROP arm left the test green, because this comment's own first draft mis-cited manifest.rs's backticked prose mention as a matching hit when every producer pattern requires an open paren. The strip is disclosed as defensive, and its value re-measured against the input it actually defends — a doc comment quoting a call site, green with the strip and found 3 without.

Second review round (resolved at head 815aaf279, census widened at 8f2836349)

Five findings; four real, one declined with evidence. Each was verified against a run before anything was edited.

  • Rows are keyed on probe identity. firing_cell decided "this is the control" from mounts_reached == 0 while mutation_cell decided the same fact from probe.mutations.is_empty() — two discriminators for one concept, agreeing only because validate refuses an empty mutation file list. Latent, and the digest pins the same count, so nothing would have disagreed with a lying row. Rendered output byte-identical: the committed block is unchanged in git and check exits 0.
  • An outcome-less probe gets a visible unmeasured row, matching what digest already records for the same input. Skipping it showed N-1 rows for N declared probes while check stayed CLEAN, because the committed copy omitted the same row.
  • A blank [target].filter is refused. Measured with filter_match = "substring": the run widens to the whole target binary and is caught only because a widened test happens to fail — reported as a broken control, not as the manifest defect. With exact it matches nothing and the execution floor blames the target for a typo in the manifest.
  • probe-pin-e2e gets its own CARGO_TARGET_DIR, having shared one with probe-pin-check while both watch crates/probe-pin/ and both allow parallel runs. The contention is cross-process, so the in-process mutex in the suite does not span it.

Declined, with measurement: a finding that validate admits identity mutations. It does; the refusal is not in validate. find == replace, repeat = 0 and text = "" each abort at exit 2 on "the materialized mutant is byte-identical to the original" — one guard after materialization, covering the class regardless of mutation kind, and find = "" is refused by the occurrence gate. The proposed fix would have added narrower per-variant checks at a worse surface, which is the field-enumeration shape this crate already repaired twice.

One of my own fixes was wrong and the suite caught it. The first attempt at the missing-row finding used unreachable!, as suggested. It panicked in the projection-surface test, which calls render with &[] outcomes deliberately — render is public and that input is legitimate, so the invariant belongs to main's call order, not to render. The visible unmeasured row closes the actual finding without breaking correct use. A second test listed "" among benign filter values; it now asserts which guard refuses it, so the benign list still proves the operand rule is discriminating.

The writer census was widened after an independent re-run. A reviewer ran it with a wider producer set (fs::copy, fs::rename added) and got the same answer — but the agreement was luck: neither appears in any src/ module today, so a future fs::copy(tmp, out_path) would have written the pinned file while the census still reported "exactly 2". The patterns are now live rather than decorative: injecting an fs::copy into block.rs fails the census at found 3, naming the module, where before the widening it was invisible.

Claimed parse impact

None. No engine or parser source is touched, so no card parse is affected.

Scope Expansion

Not "None" — disclosed in full.

The delivered crate is 6,990 LOC (2,686 src/ + 4,304 tests/) against a planned ~1,600. The estimate was wrong, not the code: the dominant drivers are rustfmt physical lines against logical-line estimates, verbatim failure-message texts, and two-sided arms on every verification row. Tests were deliberately not trimmed to hit the estimate — cutting evidence to match a projection is the exact failure this tool exists to prevent.

Thirteen items were built outside the original plan's inventory. An independent review adjudicated them 4 necessary, 9 defensible seams, 0 scope creep. The two load-bearing ones:

  • isolate::exit_rc — required. timeout re-raises the child's signal, so ExitStatus::code() is None on an abort, making the plan's own rc-134 contract unreachable without a 128+signal mapping. Measured 134/139/101/124.
  • The mount script's indexed loop — the plan's script body was written for one mutant/target, but a five-file mount is an expressible probe. Closed with ${!m} indirection, no eval; "$@" still reaches the target as exactly its argv (verified under spaces, *, $HOME, quotes, and empty strings).

One planned mechanism was not shipped (isolate::capability()): the pipeline has no step for it and the namespace-unavailable abort is reachable through the ordered classification.

The class-closure pass (final commit)

Rounds 1–3 each fixed the fields a reviewer named, and the next round found the same defect in a different field. The closure diagnosis: the crate validated FIELDS, but every defect entered through a SURFACE. Fields are unbounded and grow with the schema; surfaces are few and each has exactly one construction site. That is why a gate written for [target].args missed [target].filter, and a path guard written for mutation.file missed [output].file.

The four surfaces a manifest value can reach are argv, env, filesystem path, and anchor semantics. Validation moved to each surface's construction site.

The general guarantee is the execution floor. observe took test_count from the suite-started record and discarded passed/failed/ignored from the terminal record, so the "this run measured nothing" guard was structurally blind to a run whose every selected test was skipped — a normal run and an all-ignored run both report test_count 1. The floor is now passed + failed == 0. It caught --bench without --bench ever being enumerated, and it closes the route that needed no manifest change at all: a plain #[ignore] on the pinned test, which kept the digest, the block and the Tilt resource green indefinitely.

Deliberately not done: adding --bench (or --ignored, --list, --logfile) to the reserved-flag list. Enumerating hostile flags is the denylist shape that had already failed twice in this crate. The argv work instead closes the field gap structurally and constrains filter by positive shape — a filter is a substring, a projection path is a directory, so neither may begin with -.

Two findings surfaced by the closure work itself and fixed in it:

  • A live false green. [[projection]].paths = ["-h"] made ast-grep print its help, which probe-pin counted into a rendered "named at 30 sites." sentence at exit 0. Same argv surface, different construction site — closed by the same positive shape rule, which is the argument for writing it that way.
  • Path containment was the wrong shape, not merely under-applied. is_workspace_relative was lexical, so a symlink whose every component is an ordinary name escaped it — measured: the block spliced to a destination outside the workspace at exit 0. Replaced with a realpath assertion matching the pattern isolate::scratch_dir already used, closing the probe-id, [output].file and mount-target producers together. Not reachable in this repo today (4 tracked symlinks, all in-tree), so this is hardening — but it is the shape that let three consecutive rounds each patch one more field.

Validation Failures

None.

CI Failures

One disclosed risk materialized on the first CI run and has been remedied; one remains open.

  1. RESOLVED — the Tier-2 suite cannot run on GitHub runners. This was disclosed here as a risk with the remedy deliberately deferred until a log existed to name the cause. The log exists: actions run 31646292055unshare --map-root-user --mount fails with write failed /proc/self/uid_map: Operation not permitted, and 14 of the 15 tests then in the suite failed for that one reason, across all four shards. The Rust (fmt, clippy, test, coverage-gate) job is an aggregator reporting RUST_TEST_RESULT: failure, not an independent failure.

    Remedied by #[ignore] on the suite, with the cause and the local invocation recorded in the file header and docs/probe-pin.md. Ignored whole rather than per failing test: the lone survivor (proj_missing_both_paths) passes only because its manifest is refused at validation before unshare is reached — a property of that fixture, not of the test. Splitting on "does this manifest happen to abort early" is a boundary that moves silently the next time a fixture or refusal order changes.

    Two controls keep the accommodation from decaying: the Tilt probe-pin-e2e resource runs the suite with --ignored in the local venue, which has the capability, so "ignored in CI" means "run elsewhere" and not "never run"; and pure_logic::tier2_suite_is_uniformly_ignored_with_the_measured_reason pins the suite size, the attribute on every test, and the exact reason text, so a new Tier-2 test cannot silently join and the ignored set cannot silently widen. Two-sided: removing one attribute and rewording one reason each fail naming isolation_e2e.rs:130; adding an 18th test fails the size assertion instead.

    What CI still covers: all 68 executed pure_logic tests — schema, validation, verdict, digest, drift, and every surface refusal — but never a real mount. Stated plainly rather than left implied.

  2. probe-pin-check cannot be verified from a worktree. Tilt watches the main checkout, so a Tiltfile edit in a worktree is not what Tilt evaluates — that is a "cannot answer", not a failure. Verified instead by Starlark parse plus running the resource's cmd directly (exit 0). Closing the Tilt-side gate is owned by me, post-merge.

@lgray
lgray requested a review from matthewevans as a code owner August 12, 2026 22:17
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6124919c-0849-4c5b-9a79-613094bb22ae

📥 Commits

Reviewing files that changed from the base of the PR and between 2a47fca and 815aaf2.

📒 Files selected for processing (4)
  • Tiltfile
  • crates/probe-pin/src/block.rs
  • crates/probe-pin/src/manifest.rs
  • crates/probe-pin/tests/pure_logic.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • Tiltfile
  • crates/probe-pin/src/block.rs

📝 Walkthrough

Walkthrough

The PR adds the probe-pin crate and CLI. It validates manifests, isolates mutated test runs, evaluates probe verdicts, generates digest-stamped blocks, detects drift, and adds fixtures, tests, documentation, Cargo wiring, and Tilt automation.

Changes

Probe-pin execution and validation

Layer / File(s) Summary
Manifest contracts and execution preflight
crates/probe-pin/Cargo.toml, crates/probe-pin/src/lib.rs, crates/probe-pin/src/manifest.rs, crates/probe-pin/src/target.rs
Defines manifest schemas, validation rules, error types, workspace checks, and test-binary resolution.
Mutation materialization and isolated execution
crates/probe-pin/src/mutate.rs, crates/probe-pin/src/isolate.rs, crates/probe-pin/src/project.rs
Applies sequential mutations, creates bind mounts, controls child arguments and environment, fingerprints files, and runs projection tools.
CLI orchestration and probe verdicts
crates/probe-pin/src/main.rs, crates/probe-pin/src/verdict.rs
Runs control and mutation probes, parses libtest output, evaluates expectations, checks probe discrimination, and reports failures.
Digest, block rendering, and drift checks
crates/probe-pin/src/block.rs, crates/probe-pin/src/main.rs
Generates deterministic marked blocks, compares committed output, classifies drift, and controls safe splicing.
Fixtures, end-to-end coverage, and project wiring
crates/probe-pin/tests/*, docs/probe-pin.md, .cargo/config.toml, Tiltfile
Adds validation and isolation fixtures, integration tests, usage documentation, a Cargo alias, and automated Tilt checks.

Estimated code review effort: 5 (Critical) | ~120 minutes

Mergeability Score: 🟠 High · up to 815aa

The current implementation can still produce misleading pinned evidence or reject valid projects: no-op mutations may be reported as measured mutants, malformed markers can break comment extraction, symlinked worktrees can fail incorrectly, and parallel test execution can contend on shared fixtures. Because these issues affect the correctness and reliability of generated claims, the change is not merge-ready until they are fixed or explicitly accepted.

Sequence Diagram(s)

sequenceDiagram
  participant ProbePinCLI
  participant isolate
  participant TestBinary
  participant verdict
  ProbePinCLI->>isolate: Execute control and mutation probes
  isolate->>TestBinary: Run with mounts, arguments, and environment
  TestBinary-->>isolate: Return output and exit status
  isolate-->>ProbePinCLI: Return Run result
  ProbePinCLI->>verdict: Parse output and evaluate expectations
  verdict-->>ProbePinCLI: Return verdict or mismatch
Loading

Suggested labels: enhancement, quality

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the new probe-manifest runner and its evidence-pinning purpose.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (5)
crates/probe-pin/src/mutate.rs (1)

62-75: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Bound Prepend::repeat at validation time.

text.repeat(*repeat as usize) allocates text.len() * repeat bytes. The schema accepts any u32, so a manifest declaring a large repeat with a non-trivial text terminates probe-pin through allocation failure. That path produces no Abort, so the operator gets no named reason and no remedy. Every other hostile manifest value in this crate is refused by name before a target runs.

Add the product bound to manifest::validate, next to the existing per-mutation empty-file-list refusal, and keep the cast off the manifest value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/src/mutate.rs` around lines 62 - 75, Add validation in
manifest::validate for Mutation::Prepend::repeat using the product bound needed
to prevent text.repeat allocation failures, alongside the existing
empty-file-list validation. Reject oversized values with a named error and
remedy, and update the mutation execution path to use the validated bounded
value without casting the raw manifest repeat.
crates/probe-pin/src/block.rs (2)

138-145: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the _ arms with exhaustive matches over Verdict.

mounts_reached and provenance both use _ => over Option<&Verdict>. A new Verdict variant compiles silently and serializes as null in both fields, so the digest stops pinning its payload. Match Some(Verdict::Pass { .. }), Some(Verdict::Fail { .. }) and None explicitly, as the verdict field above already does.

As per coding guidelines: "wildcard _ match arms where the enum is known and an exhaustive match would let the compiler catch missing variants" is a finding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/src/block.rs` around lines 138 - 145, Replace the wildcard
arms in the mounts_reached and provenance matches with explicit Option<&Verdict>
cases: handle Some(Verdict::Pass { .. }), Some(Verdict::Fail { .. }), and None.
Preserve the current extracted values and None results while making future
Verdict variants compiler-visible, matching the exhaustive handling used by the
verdict field.

Source: Coding guidelines


362-375: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The Instrument variant list is duplicated here and in used_instruments.

instrument_lines hard-codes [Instrument::Toolchain, Instrument::AstGrep]. A new Instrument variant compiles without a change here. Its committed // instrument <tool> = <version> line is then never parsed, changed_instruments never sees a move for it, and write_refusal silently stops covering it. Expose one authoritative list on the enum and read it from both call sites, so the compiler reports a missing entry.

♻️ Proposed refactor
// in the module that defines `Instrument`
impl Instrument {
    pub const ALL: [Instrument; 2] = [Instrument::Toolchain, Instrument::AstGrep];
}
-            [Instrument::Toolchain, Instrument::AstGrep]
-                .into_iter()
+            Instrument::ALL
+                .into_iter()
                 .find(|i| i.tool() == name)
                 .map(|i| (i, version.to_string()))

As per coding guidelines: "any new helper that duplicates an existing building block" is a finding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/src/block.rs` around lines 362 - 375, Add an authoritative
`Instrument::ALL` collection in the enum’s implementation containing every
variant, and update `instrument_lines` to iterate over it instead of its
hard-coded variant array. Reuse the same collection in `used_instruments`,
preserving both callers’ existing behavior so adding a new variant requires
updating one compiler-checked list.

Source: Coding guidelines

crates/probe-pin/tests/pure_logic.rs (1)

1729-1736: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The reach guard asserts libtest's exact JSON spacing.

run.stdout.contains("\"test_count\": 1") requires one space after the colon. libtest's JSON format is an unstable surface, reached here through -Z unstable-options. If a future libtest emits compact JSON, this reach guard fails with "libtest must still report test_count 1, or this test pins nothing" — a message that reads as a regression in the execution floor when the real cause is a formatter change. The synthetic stream helper in this same file already writes the compact form "test_count":{n}, so both spellings exist in the file.

Read the field instead of matching bytes.

♻️ Proposed fix
     for (name, run) in [("ignored", &ignored), ("--bench", &benched)] {
+        let started = run
+            .stdout
+            .lines()
+            .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
+            .find(|v| v["type"] == "suite" && v["event"] == "started")
+            .unwrap_or_else(|| panic!("{name}: no suite-started record: {}", run.stdout));
         assert!(
-            run.stdout.contains("\"test_count\": 1"),
+            started["test_count"] == 1,
             "{name}: libtest must still report test_count 1, or this test pins nothing: {}",
             run.stdout
         );

This needs serde_json as a dev-dependency of the crate; verdict::observe already parses these records, so an exported helper would avoid the direct dependency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/tests/pure_logic.rs` around lines 1729 - 1736, Update the
reach-guard assertions in the loop over “ignored” and “--bench” to parse
run.stdout as JSON and inspect the numeric test_count field instead of matching
formatted bytes. Add the required serde_json dev-dependency, or reuse an
exported helper such as verdict::observe if that avoids the direct dependency,
while preserving the existing success and test_count validation.
crates/probe-pin/tests/fixtures/astgrep_six.sh (1)

1-7: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove or use astgrep_six.sh in a test. The fixture is not referenced by the supplied tests. pure_logic::proj_missing uses astgrep_broken.sh and astgrep_three.sh, while pure_logic::instrument_skew and pure_logic::write_refusal generate the six-match case in skew_arms().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/tests/fixtures/astgrep_six.sh` around lines 1 - 7, Remove
the unused astgrep_six.sh fixture, or add it to a test that specifically
exercises its six-match behavior. Prefer removal unless an existing test can
reuse it without duplicating the cases generated by pure_logic::instrument_skew
and pure_logic::write_refusal through skew_arms().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/probe-pin/src/block.rs`:
- Around line 178-180: Make the anchor rendering path marker-safe before it
reaches cell: validate anchors to reject control characters and PROBE-PIN marker
text, or escape them so they cannot create extra lines or marker tags. Update
the anchor-specific validation/rendering logic, while preserving existing claim
and id handling and ensuring run --write emits exactly one END marker.

In `@crates/probe-pin/src/isolate.rs`:
- Around line 54-89: Define a separate constant for the unconditional owned flag
sequence in addition to OWNED_FLAGS, while keeping --exact in OWNED_FLAGS as the
conditional flag. Update argv to iterate over the new unconditional constant
instead of duplicating the inline list, preserving the existing conditional
--exact insertion and argument order.

In `@crates/probe-pin/src/main.rs`:
- Around line 101-118: Canonicalize the workspace root before comparing it with
the canonicalized manifest path in the manifest_rel flow. Update the root
obtained from target::workspace_root once, preserve the existing containment
error handling, and use that canonical root for subsequent root.join and
current_dir operations.

In `@crates/probe-pin/src/manifest.rs`:
- Around line 414-420: Extend validate to apply producer-level checks to every
Projection: require sentence to contain the "{count}" placeholder, require id to
be a plain name, reject duplicate projection ids, reject empty or
whitespace-only anchors as applicable to projection paths, and reject empty
paths. Keep these validations before any target execution and align their
behavior with the existing probe validation rules and error handling.
- Around line 105-110: Update Output validation to reject marker values whose
trimmed content is empty, while preserving non-empty markers as provided.
Implement this in the existing validate flow for Output, before block::locate
constructs structural tags, using the existing validation error conventions.

In `@crates/probe-pin/src/verdict.rs`:
- Around line 41-75: Update observe to reserve and check a distinct run.rc value
for script-level failures before the stream/marker classification, returning the
typed Abort variant for script execution failure with the probe and trimmed
stderr. Ensure this check covers non-zero script exits with empty stdout,
including missing PATH commands or bash failing before exec, and preserves the
existing mount, timeout, and isolation precedence.

In `@crates/probe-pin/tests/fixtures/projection.toml`:
- Around line 14-16: Update the [output].file entries in
crates/probe-pin/tests/fixtures/projection.toml#L14-L16 and
crates/probe-pin/tests/fixtures/treewrite.toml#L20-L22 to use separate paths
under crates/probe-pin/tests/fixtures/tmp/, ensuring neither fixture writes the
committed dogfood_block.md; no other changes are required.

In `@crates/probe-pin/tests/isolation_e2e.rs`:
- Around line 576-593: Add an assertion after the chained replacements building
base and before tmp_manifest that verifies base differs from the original
dogfood manifest content, matching the existing edit != base reach-guard
pattern. Keep the current benign-arm execution and assertion unchanged.
- Around line 86-101: Add a probe-pin-serial test group with max-threads = 1 in
.config/nextest.toml and assign the probe-pin isolation tests that invoke
probe_pin_bin to it. Update the comment near SERIAL to clarify that the mutex
only serializes tests within a process, while the nextest group provides
cross-process serialization.

---

Nitpick comments:
In `@crates/probe-pin/src/block.rs`:
- Around line 138-145: Replace the wildcard arms in the mounts_reached and
provenance matches with explicit Option<&Verdict> cases: handle
Some(Verdict::Pass { .. }), Some(Verdict::Fail { .. }), and None. Preserve the
current extracted values and None results while making future Verdict variants
compiler-visible, matching the exhaustive handling used by the verdict field.
- Around line 362-375: Add an authoritative `Instrument::ALL` collection in the
enum’s implementation containing every variant, and update `instrument_lines` to
iterate over it instead of its hard-coded variant array. Reuse the same
collection in `used_instruments`, preserving both callers’ existing behavior so
adding a new variant requires updating one compiler-checked list.

In `@crates/probe-pin/src/mutate.rs`:
- Around line 62-75: Add validation in manifest::validate for
Mutation::Prepend::repeat using the product bound needed to prevent text.repeat
allocation failures, alongside the existing empty-file-list validation. Reject
oversized values with a named error and remedy, and update the mutation
execution path to use the validated bounded value without casting the raw
manifest repeat.

In `@crates/probe-pin/tests/fixtures/astgrep_six.sh`:
- Around line 1-7: Remove the unused astgrep_six.sh fixture, or add it to a test
that specifically exercises its six-match behavior. Prefer removal unless an
existing test can reuse it without duplicating the cases generated by
pure_logic::instrument_skew and pure_logic::write_refusal through skew_arms().

In `@crates/probe-pin/tests/pure_logic.rs`:
- Around line 1729-1736: Update the reach-guard assertions in the loop over
“ignored” and “--bench” to parse run.stdout as JSON and inspect the numeric
test_count field instead of matching formatted bytes. Add the required
serde_json dev-dependency, or reuse an exported helper such as verdict::observe
if that avoids the direct dependency, while preserving the existing success and
test_count validation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b1551b4-7187-450f-af2d-d2620a6ab516

📥 Commits

Reviewing files that changed from the base of the PR and between 479ad39 and ed64153.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (30)
  • .cargo/config.toml
  • Tiltfile
  • crates/probe-pin/Cargo.toml
  • crates/probe-pin/src/block.rs
  • crates/probe-pin/src/isolate.rs
  • crates/probe-pin/src/lib.rs
  • crates/probe-pin/src/main.rs
  • crates/probe-pin/src/manifest.rs
  • crates/probe-pin/src/mutate.rs
  • crates/probe-pin/src/project.rs
  • crates/probe-pin/src/target.rs
  • crates/probe-pin/src/verdict.rs
  • crates/probe-pin/tests/fixtures/astgrep_broken.sh
  • crates/probe-pin/tests/fixtures/astgrep_six.sh
  • crates/probe-pin/tests/fixtures/astgrep_three.sh
  • crates/probe-pin/tests/fixtures/collide.toml
  • crates/probe-pin/tests/fixtures/compiled.toml
  • crates/probe-pin/tests/fixtures/control_expect_fail.toml
  • crates/probe-pin/tests/fixtures/control_fails.toml
  • crates/probe-pin/tests/fixtures/dogfood.toml
  • crates/probe-pin/tests/fixtures/dogfood_block.md
  • crates/probe-pin/tests/fixtures/p7.toml
  • crates/probe-pin/tests/fixtures/p8.toml
  • crates/probe-pin/tests/fixtures/prod.txt
  • crates/probe-pin/tests/fixtures/projection.toml
  • crates/probe-pin/tests/fixtures/treewrite.toml
  • crates/probe-pin/tests/fixtures/unsorted_ids.toml
  • crates/probe-pin/tests/isolation_e2e.rs
  • crates/probe-pin/tests/pure_logic.rs
  • docs/probe-pin.md

Comment thread crates/probe-pin/src/block.rs
Comment thread crates/probe-pin/src/isolate.rs
Comment thread crates/probe-pin/src/main.rs Outdated
Comment on lines +101 to +118
let root = target::workspace_root()?;
// Workspace-relative or nothing: this string reaches the digest AND the BEGIN line, so an
// absolute path stamps a machine-specific value into a committed artifact — the exact
// reproducibility the digest exists to provide (docs/probe-pin.md: "Excluded: … absolute
// paths"), and the treatment `is_workspace_relative` already gives every other path key.
let manifest_rel = manifest_path
.canonicalize()
.ok()
.and_then(|p| p.strip_prefix(&root).ok().map(Path::to_path_buf))
.with_context(|| {
format!(
"probe-pin: manifest {} is not inside the workspace root {}. Its path is stamped into the block's BEGIN line and into the digest, so an absolute one would pin a block that no other checkout can reproduce. Move the manifest into the workspace and name it relative to the root. Aborting.",
manifest_path.display(),
root.display()
)
})?
.display()
.to_string();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

strip_prefix compares a canonicalized manifest path against a non-canonical root, so a symlinked workspace path aborts a valid run.

target::workspace_root returns the parent of the cargo locate-project output without canonicalizing it. Line 107 canonicalizes the manifest path. If any component of the workspace path is a symlink, the two strings do not share a prefix, strip_prefix returns Err, and the pipeline aborts with "manifest … is not inside the workspace root". That statement is false, and no manifest change can satisfy it.

The other two containment assertions in this crate canonicalize both sides: isolate::scratch_dir canonicalizes the scratch dir and the workspace, and manifest::resolve_contained canonicalizes base. Apply the same rule here. Canonicalizing root once also keeps root.join(&m.output.file) and the ast-grep current_dir(root) consistent with it.

🐛 Proposed fix
     // 2. workspace root
-    let root = target::workspace_root()?;
+    // Canonicalized at the source: `manifest_rel` below and `resolve_contained` both compare
+    // against it after canonicalizing the other side, and a symlinked worktree path otherwise
+    // fails a prefix comparison that is actually satisfied.
+    let root = target::workspace_root()?;
+    let root = root.canonicalize().unwrap_or(root);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let root = target::workspace_root()?;
// Workspace-relative or nothing: this string reaches the digest AND the BEGIN line, so an
// absolute path stamps a machine-specific value into a committed artifact — the exact
// reproducibility the digest exists to provide (docs/probe-pin.md: "Excluded: … absolute
// paths"), and the treatment `is_workspace_relative` already gives every other path key.
let manifest_rel = manifest_path
.canonicalize()
.ok()
.and_then(|p| p.strip_prefix(&root).ok().map(Path::to_path_buf))
.with_context(|| {
format!(
"probe-pin: manifest {} is not inside the workspace root {}. Its path is stamped into the block's BEGIN line and into the digest, so an absolute one would pin a block that no other checkout can reproduce. Move the manifest into the workspace and name it relative to the root. Aborting.",
manifest_path.display(),
root.display()
)
})?
.display()
.to_string();
// 2. workspace root
// Canonicalized at the source: `manifest_rel` below and `resolve_contained` both compare
// against it after canonicalizing the other side, and a symlinked worktree path otherwise
// fails a prefix comparison that is actually satisfied.
let root = target::workspace_root()?;
let root = root.canonicalize().unwrap_or(root);
// Workspace-relative or nothing: this string reaches the digest AND the BEGIN line, so an
// absolute path stamps a machine-specific value into a committed artifact — the exact
// reproducibility the digest exists to provide (docs/probe-pin.md: "Excluded: … absolute
// paths"), and the treatment `is_workspace_relative` already gives every other path key.
let manifest_rel = manifest_path
.canonicalize()
.ok()
.and_then(|p| p.strip_prefix(&root).ok().map(Path::to_path_buf))
.with_context(|| {
format!(
"probe-pin: manifest {} is not inside the workspace root {}. Its path is stamped into the block's BEGIN line and into the digest, so an absolute one would pin a block that no other checkout can reproduce. Move the manifest into the workspace and name it relative to the root. Aborting.",
manifest_path.display(),
root.display()
)
})?
.display()
.to_string();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/src/main.rs` around lines 101 - 118, Canonicalize the
workspace root before comparing it with the canonicalized manifest path in the
manifest_rel flow. Update the root obtained from target::workspace_root once,
preserve the existing containment error handling, and use that canonical root
for subsequent root.join and current_dir operations.

Comment on lines +105 to +110
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Output {
pub file: String,
pub marker: String,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how [output].marker is consumed when constructing and matching block markers.
fd -t f 'block.rs' crates/probe-pin/src --exec rg -n -C4 'marker'
rg -n -C3 'marker' crates/probe-pin/tests

Repository: phase-rs/phase

Length of output: 20028


Reject blank [output].marker values. block::locate constructs {marker}:BEGIN and {marker}:END without normalization, so blank or whitespace-only markers produce ambiguous structural tags and can match unrelated lines. Reject markers whose trimmed value is empty during validate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/src/manifest.rs` around lines 105 - 110, Update Output
validation to reject marker values whose trimmed content is empty, while
preserving non-empty markers as provided. Implement this in the existing
validate flow for Output, before block::locate constructs structural tags, using
the existing validation error conventions.

Comment thread crates/probe-pin/src/manifest.rs
Comment thread crates/probe-pin/src/verdict.rs
Comment thread crates/probe-pin/tests/fixtures/projection.toml
Comment on lines +86 to +101
fn probe_pin_bin(args: &[&str], env: &[(&str, &str)]) -> (i32, String) {
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _serial = SERIAL
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut cmd = Command::new(env!("CARGO_BIN_EXE_probe-pin"));
cmd.current_dir(root()).args(args);
for (k, v) in env {
cmd.env(k, v);
}
let out = cmd.output().expect("probe-pin runs");
(
isolate::exit_rc(&out.status),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for an existing nextest configuration and any current test-group usage.
fd -H -t f 'nextest.toml' . ; rg -n 'test-group|test-groups|max-threads' -- .config .cargo 2>/dev/null

Repository: phase-rs/phase

Length of output: 175


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- nextest config ---'
cat -n .config/nextest.toml
printf '%s\n' '--- cargo config ---'
cat -n .cargo/config.toml
printf '%s\n' '--- isolation test structure ---'
wc -l crates/probe-pin/tests/isolation_e2e.rs
rg -n 'probe_pin_bin|#\[test\]|fixtures/tmp|cargo test|CARGO_TARGET_DIR|root\(' crates/probe-pin/tests/isolation_e2e.rs crates/probe-pin -g '*.rs'
printf '%s\n' '--- package metadata ---'
rg -n 'name\s*=\s*"probe-pin"|nextest|test-all' Cargo.toml crates .cargo .config -g '*.toml' -g '*.md'

Repository: phase-rs/phase

Length of output: 25162


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- tested function names and helper call sites ---'
python3 - <<'PY'
import re
from pathlib import Path
p = Path("crates/probe-pin/tests/isolation_e2e.rs")
lines = p.read_text().splitlines()
for i, line in enumerate(lines):
    if line.strip() == "#[test]":
        j = i + 1
        while j < len(lines) and not lines[j].strip():
            j += 1
        m = re.search(r'fn\s+([A-Za-z0-9_]+)\s*\(', lines[j])
        if m:
            print(f"{i+1}: {m.group(1)}")
PY
printf '%s\n' '--- helper documentation and nearby implementation ---'
sed -n '70,108p' crates/probe-pin/tests/isolation_e2e.rs
printf '%s\n' '--- test bodies that invoke the helper ---'
sed -n '300,465p' crates/probe-pin/tests/isolation_e2e.rs
sed -n '460,730p' crates/probe-pin/tests/isolation_e2e.rs
printf '%s\n' '--- exact nextest-related repository references ---'
rg -n -C 3 'nextest|test-groups|test-group|probe-pin-serial' . -g '!target' -g '!node_modules'

Repository: phase-rs/phase

Length of output: 38277


🌐 Web query:

cargo nextest documentation test groups max-threads process per test

💡 Result:

In cargo-nextest, you can control the concurrency of specific sets of tests using test groups and the max-threads parameter [1][2]. Configuration Setup To implement this, you define test groups in your.config/nextest.toml file, specifying a maximum number of concurrent threads allowed for that group [1][2]: [test-groups] my-group = { max-threads = 4 } Assigning Tests After defining the group, you assign tests to it using per-test overrides in the same configuration file. You use a filter string to target specific tests [1][2]: [[profile.default.overrides]] filter = 'test(/^my_test_prefix::/)' test-group = 'my-group' Important Considerations - Concurrency Limits: The max-threads setting for a test group acts as a logical semaphore, limiting the number of tests in that group running concurrently to the specified number [1][2]. - Scope of Exclusion: Test groups provide mutual exclusion only among tests within that specific group [3][4]. They do not prevent tests in the group from running concurrently with tests that are not part of the group [1][3]. - Threads-Required: If you need to manage how many threads a single test consumes (e.g., for heavy tests), you can use the threads-required per-test override [1][5]. This is distinct from test groups; threads-required sets the weight of a single test against the global or group-specific thread pool [1][5]. A test can simultaneously belong to a test group and have a threads-required value assigned to it [1][2]. For more detailed information, refer to the official nextest documentation on test groups [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- nested cargo command and environment handling ---'
sed -n '1,115p' crates/probe-pin/src/target.rs
rg -n -C 8 'Command::new|cargo|CARGO_TARGET_DIR|current_dir|env_clear|envs' crates/probe-pin/src crates/probe-pin/tests/isolation_e2e.rs
printf '%s\n' '--- read-only coverage check for the proposed nextest filter ---'
python3 - <<'PY'
import re
from pathlib import Path
text = Path("crates/probe-pin/tests/isolation_e2e.rs").read_text()
tests = []
for m in re.finditer(r'#\[test\]\s*fn\s+([A-Za-z0-9_]+)\s*\(', text):
    name = m.group(1)
    body_start = m.end()
    next_test = re.search(r'\n#\[test\]', text[body_start:])
    body = text[body_start:body_start + (next_test.start() if next_test else len(text))]
    if 'probe_pin_bin(' in body:
        tests.append(name)
print("tests invoking probe_pin_bin:", tests)
pattern = re.compile(
    r'^(?:mount_reach|pad_reach|tree|drift|no_tests_selected_ordering|'
    r'wiring_|execution_floor_|dogfood_check|proj_missing_both_paths|streams_are_separate)'
)
print("covered by proposed regex:", [n for n in tests if pattern.search(n)])
print("not covered:", [n for n in tests if not pattern.search(n)])
print("helper call expressions:", len(re.findall(r'\bprobe_pin_bin\(', text)) - 1)
PY

Repository: phase-rs/phase

Length of output: 31743


Serialize the probe_pin_bin tests under nextest.

SERIAL is process-local. Nextest runs each test in a separate process, so the tests that call probe_pin_bin do not share this lock. Each child inherits CARGO_TARGET_DIR and runs cargo test -p probe-pin --test pure_logic --no-run, which causes concurrent Cargo-build contention. Add a probe-pin-serial test group with max-threads = 1 in .config/nextest.toml, and update the comment to distinguish this cross-process guarantee from the in-process mutex.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/tests/isolation_e2e.rs` around lines 86 - 101, Add a
probe-pin-serial test group with max-threads = 1 in .config/nextest.toml and
assign the probe-pin isolation tests that invoke probe_pin_bin to it. Update the
comment near SERIAL to clarify that the mutex only serializes tests within a
process, while the nextest group provides cross-process serialization.

Comment thread crates/probe-pin/tests/isolation_e2e.rs Outdated
Comment on lines +576 to +593
let base = std::fs::read_to_string(root().join("crates/probe-pin/tests/fixtures/dogfood.toml"))
.unwrap()
// a probe whose mutant leaves the census passing: the shape that renders a green `pass`
// row, which is what a skipped run forges
.replace(
" find = \"SITE-TWO marker beta\\n\"\n replace = \"\"",
" find = \"alpha\"\n replace = \"gamma\"",
)
.replace(" text = \"marker\"\n count = 1", " text = \"gamma\"\n count = 1")
.replace(
" outcome = \"fail\"\n anchor = [\"CENSUS VIOLATED: expected 2 sites, got 1\",\n \"text: \\\"SITE-ONE marker alpha\\\\n\\\"\"]",
" outcome = \"pass\"",
);
// the reach guard: this manifest must be GREEN when the target really runs, or every arm
// below aborts for a reason that has nothing to do with the floor
let (_, arg) = tmp_manifest("floor_base.toml", &base);
let (rc, err) = probe_pin_bin(&["run", &arg], &[]);
assert_eq!(rc, 0, "the benign arm must pass: {err}");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a reach guard for the three base edits.

base is built from dogfood.toml by three chained replace calls. None is asserted to have applied. If dogfood.toml's formatting drifts by one space, all three become no-ops and base equals the committed manifest. The reach guard on line 593 still passes, because the unedited dogfood.toml also exits 0. Both hostile arms then abort on the original manifest and stay green for a reason unrelated to the "mutant leaves the census passing" shape this test pins.

The loop below already asserts edit != base for exactly this reason. Apply the same rule here.

💚 Proposed fix
+    let original =
+        std::fs::read_to_string(root().join("crates/probe-pin/tests/fixtures/dogfood.toml"))
+            .unwrap();
+    assert_ne!(
+        base, original,
+        "the base edits must apply, or both hostile arms pin the unedited manifest"
+    );
     // the reach guard: this manifest must be GREEN when the target really runs, or every arm
     // below aborts for a reason that has nothing to do with the floor
     let (_, arg) = tmp_manifest("floor_base.toml", &base);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let base = std::fs::read_to_string(root().join("crates/probe-pin/tests/fixtures/dogfood.toml"))
.unwrap()
// a probe whose mutant leaves the census passing: the shape that renders a green `pass`
// row, which is what a skipped run forges
.replace(
" find = \"SITE-TWO marker beta\\n\"\n replace = \"\"",
" find = \"alpha\"\n replace = \"gamma\"",
)
.replace(" text = \"marker\"\n count = 1", " text = \"gamma\"\n count = 1")
.replace(
" outcome = \"fail\"\n anchor = [\"CENSUS VIOLATED: expected 2 sites, got 1\",\n \"text: \\\"SITE-ONE marker alpha\\\\n\\\"\"]",
" outcome = \"pass\"",
);
// the reach guard: this manifest must be GREEN when the target really runs, or every arm
// below aborts for a reason that has nothing to do with the floor
let (_, arg) = tmp_manifest("floor_base.toml", &base);
let (rc, err) = probe_pin_bin(&["run", &arg], &[]);
assert_eq!(rc, 0, "the benign arm must pass: {err}");
let original =
std::fs::read_to_string(root().join("crates/probe-pin/tests/fixtures/dogfood.toml"))
.unwrap();
let base = std::fs::read_to_string(root().join("crates/probe-pin/tests/fixtures/dogfood.toml"))
.unwrap()
// a probe whose mutant leaves the census passing: the shape that renders a green `pass`
// row, which is what a skipped run forges
.replace(
" find = \"SITE-TWO marker beta\\n\"\n replace = \"\"",
" find = \"alpha\"\n replace = \"gamma\"",
)
.replace(" text = \"marker\"\n count = 1", " text = \"gamma\"\n count = 1")
.replace(
" outcome = \"fail\"\n anchor = [\"CENSUS VIOLATED: expected 2 sites, got 1\",\n \"text: \\\"SITE-ONE marker alpha\\\\n\\\"\"]",
" outcome = \"pass\"",
);
assert_ne!(
base, original,
"the base edits must apply, or both hostile arms pin the unedited manifest"
);
// the reach guard: this manifest must be GREEN when the target really runs, or every arm
// below aborts for a reason that has nothing to do with the floor
let (_, arg) = tmp_manifest("floor_base.toml", &base);
let (rc, err) = probe_pin_bin(&["run", &arg], &[]);
assert_eq!(rc, 0, "the benign arm must pass: {err}");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/tests/isolation_e2e.rs` around lines 576 - 593, Add an
assertion after the chained replacements building base and before tmp_manifest
that verifies base differs from the original dogfood manifest content, matching
the existing edit != base reach-guard pattern. Keep the current benign-arm
execution and assertion unchanged.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Generated for head 8f283634934b04b848122956d8211b8aa54df914.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans matthewevans self-assigned this Aug 12, 2026
@matthewevans matthewevans added the bug Bug fix label Aug 12, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: generated blocks can inject a second marker

Expect::Fail.anchor is validated only for blank and line-number forms; it still accepts a value such as "\nPROBE-PIN:END" (manifest.rs:529-545). block::cell escapes only |, and render writes that anchor directly into the generated table (block.rs:178-180, 211-216, 266-285).

run --write compares the old block then splices the generated text without validating that generated text (main.rs:214-245). The next invocation uses locate, which requires exactly one :BEGIN and one :END (block.rs:303-326). Therefore a manifest can cause run --write to create a block that its own next check cannot locate (MarkerNotUnique), breaking the regenerable/re-measurable artifact guarantee.

Please make every manifest-derived rendered field marker-safe (at minimum, reject control/newline characters and marker tags where they can reach generated lines, including marker and projection text as applicable), or validate the generated block's marker structure before writing. Add a regression test proving marker/newline injection is rejected and that run --write cannot produce an uncheckable block.

@matthewevans matthewevans removed their assignment Aug 12, 2026
lgray added 12 commits August 12, 2026 18:59
Adds the new `probe-pin` workspace crate's declarative layer: the TOML probe
manifest, its validation rules, and the code that turns a manifest into mutant
file copies in a scratch directory outside the workspace.

`Abort` carries all 25 refusal modes as a `thiserror` enum; every message names
what happened, why it invalidates the run, and what to do. Validation refuses
fail-closed rather than degrading: unknown schema version, `mode = "compiled"`
(deferred from v1), duplicate probe ids, a missing zero-mutation control, empty
anchor lists, anchors that embed a source line number, anchor lists that do not
discriminate between probes, and reserved libtest flags in `[target].args`.

Path keys from the manifest are validated as *content*, not sanitized at the
join site: `is_plain_name` accepts a positive charset for probe ids and
`is_workspace_relative` requires every component to be `Component::Normal`.
Both are applied to `probe.id`, to every mutation `file`, and to
`assert_count.file`. Without this a `..` in an id escapes the scratch directory
and writes into the workspace under measurement -- the tree-integrity
fingerprint cannot see such a write, because the escaped path is not in the
probe's touched set.

Mutations apply in declaration order against the running text, each `find`
gated to exactly one occurrence, and the materialized mutant is compared to the
original bytes so a no-op mutation is refused: a no-op would make the later
mount-reach readback succeed whether or not the mount happened.

Assisted-by: ClaudeCode:claude-opus-5
Adds the measuring layer. Mutants are bind-mounted over the real paths inside
an unprivileged mount namespace (`unshare --map-root-user --mount`), so the
worktree is never written; a sha256 fingerprint of every mutated file is taken
before and after the run to prove it.

The mount script reads back each mount with `cmp` before running anything: a
mutant that was materialized but not visible at the target's path would
otherwise yield a "pass" verdict that is a lie about an unmounted file. The
target runs under `timeout`, and its environment is constructed rather than
inherited (`env_clear` plus an explicit allowlist) so two developers with
different shells get byte-identical captures. That allowlist governs the target
run only -- the cargo shell-outs keep their ambient environment, which is what
preserves per-worktree `CARGO_HOME` isolation.

Verdicts are read from libtest's JSON records rather than inferred from panic
prose. Each record's `stdout` field is that test's own capture, so anchors are
scoped structurally: a passing test cannot forge another test's anchor, a
`#[should_panic]` that passes is not reachable as a failure, and a panic
message that itself contains a `thread '...' panicked at` line stays one
record. Aborts are classified in a defined order, because a killed or
mount-failed run satisfies more than one abort condition at once and the
useful diagnosis is the specific one, not the generic one.

A suite reporting zero selected tests is an instrument failure, never a pass:
without that floor a filter that rots to match nothing renders green having
measured nothing.

Assisted-by: ClaudeCode:claude-opus-5
Adds the recording layer and wires the pipeline together behind
`cargo probe-pin run [--write] <manifest>` and `cargo probe-pin check
<manifest>`.

The rendered block carries the probe table between `PROBE-PIN:BEGIN/END`
markers, stamped with a digest over *what was measured*, not merely what came
out: the whole `[target]` block, `[output]`, every probe's inputs and observed
outcome, and each projection's pattern and sorted paths. Narrowing a filter or
a projection's path set moves the digest even when every verdict and count is
identical -- that is the point, since those inputs decide what the numbers mean.
The digest is a `#[derive(Serialize)]` struct hashed via `serde_json::to_vec`,
so JSON escaping provides injection safety and declaration order provides
canonical ordering; there is no hand-rolled joiner to review.

Instrument versions (rustc always, ast-grep when the manifest declares a
projection) are digest inputs, so `check` can distinguish an instrument change
from code drift and say which. `run --write` refuses exactly when an instrument
moved *and* a measured number moved: re-stamping there would record the new
number under the new tool and destroy the evidence of which one changed it.

Bytes outside the marker pair are copied verbatim. Prose there is never read,
never validated, and never invalidated by this tool -- documented as a
non-guarantee rather than papered over with a marker that would make unchecked
prose look measured.

Assisted-by: ClaudeCode:claude-opus-5
Adds the 40-test Tier-1 suite and the fixture manifests it drives. Every row of
the plan's verification matrix is a test with a DROP arm and a TRIVIALIZE arm,
and each arm is stated in the test's own doc comment along with what it must
flip. A row whose DROP arm does not flip its assertion is vacuous, and in a tool
whose thesis is that evidence must be measured rather than asserted, a vacuous
test is self-refuting -- so the arms are the deliverable, not decoration.

Notable rows: anchors are proven scoped to one libtest record against three
hostile streams (a passing test's `eprintln!` forgery, a two-anchor forgery
split across tests, and a panic message that itself contains a panic header);
the anchor line-number lint is exercised against all ten real corpus anchors
and a hostile set; the digest is shown to move when `[target]`'s filter narrows
or a projection's path set shrinks even though every verdict and count is
identical; and injection safety is proven with actually-colliding id/claim
pairs rather than a pair that merely looks like it collides.

`path_keys_cannot_escape_the_scratch_dir` exhibits the phenomenon before
asserting the refusal: its first arm performs the traversing write and reads
the victim file back, so the test would fail if the escape ever stopped being
possible for the wrong reason. It asserts file state, not error text.

Assisted-by: ClaudeCode:claude-opus-5
Adds the 12-test Tier-2 suite that runs real mount namespaces, the user
documentation, and a `probe-pin-check` Tilt resource so `--check` has an
executor rather than existing only as a subcommand nobody runs.

Tier-2 covers what Tier-1 structurally cannot: that a mutant is actually
visible at the target's path inside the namespace, that a hung target is killed
and named rather than hanging the suite, that the mutated files are byte-
identical afterwards, and that `--check` reports drift in both directions. Three
arms pin pipeline wiring rather than function behaviour -- each was verified by
deleting the step it pins and observing that exactly one arm fails.

The Tilt resource's cost was measured rather than guessed (~0.16s steady state
against a probe-pin-owned target), which is what justifies an automatic trigger
with narrow deps. Its ignore list extends `TMP_IGNORE`, which is a filename
glob (`**/*.tmp.*`) and does not match a `tmp/` directory.

The docs state the tool's boundaries as plainly as its guarantees: prose outside
the marker block is never validated; a pass verdict proves the mutant was
visible and the assertion still passed, not that the target read the file;
`--check` costs the same target runs as `run`; and mount isolation stops
probe-pin from writing the tree, not the target.

Assisted-by: ClaudeCode:claude-opus-5
…asured tree

Final review-impl found three MAJORs sharing one class: the tool emitted a
`pass` row for something it never measured. Fixed at the producer, not the
instance, plus two findings the driver measured independently.

- MAJOR-1: a `prepend` mutation with `files = []` seeded nothing, mounted
  nothing, and rendered the control's own firing text as a mutant's verdict.
  It never REACHED the `NoOpMutation` gate. Refused per mutation, so an empty
  list cannot ride along beside one that names a file.
- MAJOR-2: step 7 hand-built `Verdict::Pass` for the control, so a control
  whose declared expectation the run refuted rendered green. `record()` is now
  the crate's only `Outcome` construction site and both run paths go through
  it; the probe whose whole job is "the instrument works" is the last one that
  should be exempt from the check that a measurement matched its expectation.
- MAJOR-3: `measured_lines` classified by text prefix, so a projection
  sentence beginning with the word "instrument" dropped out of the comparison
  and disabled the write refusal. `regions()` now splits by construction
  order, and both consumers share it so the two classifications cannot
  disagree. The reviewer's snippet fixed only one consumer.
- MINOR-4/5/6: docs claimed a `RUST_BACKTRACE` the code refuses to set;
  `timeout_secs = 0` disabled the guard it configures; an absolute manifest
  path reached the digest and the BEGIN line, pinning a block no other
  checkout could reproduce.
- Sweep-found sibling: an `assert_count` declared on the control was a digest
  input that was never evaluated, since it is checked against mutant text the
  control never materializes.

Driver-added, each measured before being believed:

- `[output].file` accepted `..` and absolute paths; `run --write` spliced the
  block into a file outside the workspace at exit 0. `check` resolves that
  path against the root, so a pin written outside it is one no checkout and no
  CI job can re-measure.
- The docs manifest example did not parse as TOML (`;` is not a separator) and
  named `package = "engine"`, which does not exist: `crates/engine` is package
  `phase-engine` with `[lib] name = "engine"`. Both shipped because an example
  is code that never runs. All ten fixtures were already correct — they are
  parsed by tests; the doc block was only read by humans. Closed with a gate
  that extracts the fence and asserts it parses, validates, and names a real
  package.

Every arm is two-sided: each new guard was reverted and its test observed to
fail with the diagnostic it exists to produce.

Assisted-by: ClaudeCode:claude-opus-5
Three review rounds each found instances of one class: probe-pin rendered a
green row, or exited 0, for a run it never measured. Each round fixed the
fields named and the next round found more in different fields. The diagnosis
is that the crate validated FIELDS while every defect entered through a
SURFACE: fields are unbounded and grow with the schema, surfaces are few and
each has exactly one construction site. That is why a gate written for
[target].args missed [target].filter, and a path guard written for
mutation.file missed [output].file.

The execution floor now measures the field that answers the question.
`observe` took test_count from the suite-STARTED record and discarded
passed/failed/ignored from the terminal record, so the "this run measured
nothing" guard was structurally blind to a run whose every selected test was
skipped. Measured: a normal run and an all-ignored run both report
test_count 1. The floor is now passed + failed == 0, and one parameterized
abort distinguishes "the filter selected nothing" from "everything selected
was skipped". This is the general guarantee: it caught --bench without
--bench ever being enumerated, and it closes the route that needed no
manifest change at all — a plain #[ignore] on the pinned test, which kept the
digest, the block and the Tilt resource green forever.

- argv: `Target::manifest_argv` is the single authority for the tokens a
  manifest contributes, and `isolate::argv` is built from it, so the two
  cannot drift and a future field cannot reach libtest unvalidated. `filter`
  and projection paths are additionally constrained by positive shape — a
  filter is a substring, a path is a directory, so neither may begin with '-'.
  Deliberately not a flag denylist: that shape already failed twice here.
- env: the manifest may not override a key probe-pin itself sets, derived
  from the keys the child command actually sets rather than hand-listed, with
  an anti-drift test that fails naming the full key set. PATH resolves the
  mount, cmp and timeout the isolation script runs, so overriding it rendered
  a green "mount reached" row for a mutant never mounted and never compared.
- path: containment is now a realpath assertion matching the pattern
  scratch_dir already used, replacing a lexical components() check that was
  blind to a symlink whose every component is an ordinary name. Closes the
  probe id, [output].file and mount-target producers together.
- anchor: an anchor empty after trimming is refused. contains("") is true for
  every capture, so `anchor = [""]` was the empty-list hazard one character
  away, rendering a blank firing-assertion cell that reads as "no anchor was
  needed".

The producer census the surface argument rests on found one more live false
green, in scope and fixed here: `[[projection]].paths = ["-h"]` made ast-grep
print its help, which probe-pin counted into a rendered "named at 30 sites"
sentence at exit 0. Same argv surface, a different construction site.

assert_count naming a file the probe does not mutate is refused rather than
silently read from the pristine tree while the message says "the MUTANT of".

Every guard is two-sided: reverted, and its test observed to fail with the
diagnostic it exists to produce.

Assisted-by: ClaudeCode:claude-opus-5
…cute

GitHub's runners deny unprivileged user namespaces: `unshare --map-root-user
--mount` fails with `write failed /proc/self/uid_map: Operation not
permitted`. Measured on actions run 31646292055 — 14 of the 15 tests in
tests/isolation_e2e.rs failed there for that one reason, spread across all
four test shards. This is the risk the PR disclosed, with the remedy
deliberately deferred until a CI log existed to name the cause rather than
guessed at pre-emptively.

The suite is ignored WHOLE rather than per failing test. The lone survivor,
proj_missing_both_paths, passes only because its manifest is refused at
validation before `unshare` is reached — a property of that fixture, not of
the test. Splitting the suite on "does this manifest happen to abort early"
creates a boundary that moves silently the next time a fixture or a refusal
order changes, and a Tier-2 test that quietly stops reaching Tier 2 is the
unmeasured-green this crate exists to refuse.

`#[ignore]` and not a runtime capability check, for the same reason: a test
that no-ops when the namespace is missing reports as PASSED. That is this
crate's own BLOCKER defect wearing a test harness. An ignored test reports as
ignored, and `cargo test -p probe-pin` now prints `15 ignored` where a
capability check would have printed a green count that measured nothing.

Verified the ignore hides no failure: `cargo test -p probe-pin --test
isolation_e2e -- --ignored` is 15 passed, 0 failed locally, where namespaces
work. CI retains all 54 pure_logic tests, which cover the schema, validation,
verdict, digest and drift logic — including every surface refusal — but never
exercise a real mount. Both facts are now in docs/probe-pin.md and the file
header.

Assisted-by: ClaudeCode:claude-opus-5
…ft guard

The Tier-2 suite is #[ignore]d because GitHub's runners deny unprivileged
user namespaces. That is a measured accommodation, and an accommodation with
nothing watching it decays into "isolation abandoned" without anyone
deciding to abandon it. Two controls, at the two ways it can rot.

- Tilt `probe-pin-e2e` runs `cargo test -p probe-pin --test isolation_e2e
  -- --ignored`. The local venue has the capability GH CI lacks, so the
  suite actually executes on every change, and a non-zero exit turns the
  resource red like any other gate. This is what makes "ignored in CI" mean
  "run elsewhere" rather than "never run".
- `pure_logic::tier2_suite_is_uniformly_ignored_with_the_measured_reason`
  pins the suite size, the attribute on every test, and the exact reason
  text. It lives in pure_logic because that is the suite CI runs — a watcher
  in the ignored file would itself be ignored. Pinning the reason verbatim
  is deliberate: the reason is the only place a reader is told the
  accommodation is about namespaces and how to run the suite anyway, so a
  reword is drift worth failing on.

The guard counts the ATTRIBUTE, never the string. This file's own doc
comments and a fixture label both contain the text `#[ignore]`, and an
unanchored match counts those too — the trap that produced a 7-vs-1
discrepancy when the suite was first measured. An instrument check asserts
the raw substring count still exceeds the attribute count, so the anchoring
rationale fails loudly if those mentions ever move.

Two-sided, all three arms measured: removing one attribute and rewording one
reason each fail naming isolation_e2e.rs:130; adding a 16th test fails on the
size assertion instead. Source restored byte-identical (`sha256sum -c` OK).
The Tilt cmd was run verbatim (exit 0, 15 passed) and the Tiltfile parses
(`tilt alpha tiltfile-result`, exit 0, Error: null) — Tilt itself watches the
main checkout, so a worktree edit cannot be confirmed through the running
Tilt, which is a cannot-answer and not a pass.

Assisted-by: ClaudeCode:claude-opus-5
`target::resolve` shelled cargo without a working directory, so it inherited
the caller's. `CARGO_TARGET_DIR` is commonly relative — the Tilt resources
here pass `target/probe-pin` — and a relative value resolves against the cwd
of whichever process finally execs cargo. Running the Tier-2 suite, whose
tests have the crate directory as their cwd, therefore materialized a 285M
`crates/probe-pin/target/` that no `.gitignore` rule covers, because the repo
ignores a root-anchored `/target`. Found while verifying the working tree was
clean before a push; no artifact ever entered a commit (0 target/ paths in the
PR range, 0 tracked).

`project::count` already pinned `current_dir(root)`; this makes the two cargo
and ast-grep handoffs consistent. `workspace_root` is deliberately left
unpinned: inheriting the caller's cwd is how it discovers the root at all.

Labelled honestly: hygiene with observed harm, not a false-verdict defect.
`resolve` returns the binary path from cargo's own JSON, so the measurement
was self-consistent either way — what moved was where the artifacts landed.

Measured both arms with the same command: pinned, nothing appears; unpinned,
285M reappears. Guarded by a SOURCE-level assertion because no behavioural
test can see it — with the pin removed the Tier-2 suite still passes 15/15,
and only the location of a side effect moves. The guard carries an instrument
control asserting the same search does NOT match `workspace_root`, so if the
function-slicing ever picks up the wrong body the test says so instead of
passing for the wrong reason. DROP arm: removing the pin fails the guard with
that exact diagnostic; source restored byte-identical.

Assisted-by: ClaudeCode:claude-opus-5
…urfaces

CodeRabbit raised 9 findings on the PR. Eight reproduced, several worse than
described, and they are resolved at their construction surfaces rather than
as nine field patches — the shape that has already failed three times in this
crate, where a gate written for [target].args missed [target].filter and a
path guard written for mutation.file missed [output].file.

- Block text is now one surface. `rendered_block_values` enumerates every
  manifest string that reaches the block — manifest path, marker, probe ids,
  mutation paths, anchors, projection sentences — and `validate_block_text`
  refuses control characters and marker tags across all of them before any
  target runs. Reproduced: an anchor of `PROBE-PIN:END` genuinely fires, is
  rendered, and leaves the file with two END tags, so `check` then aborts
  MarkerNotUnique and the pin is unrecoverable without a hand edit. A blank
  `[output].marker` was worse: it matched an UNRELATED committed block's tags
  and replaced it, keyed on a tag any line containing `:END` satisfies.
- Projections get the producer-level checks their probe siblings already had.
  All four defects rendered a block at exit 0: a duplicate id printed another
  projection's count (lookup is by id), a sentence without `{count}` rendered
  a complete-looking claim carrying no measured number, empty `paths` made
  ast-grep scan the whole cwd and report 15 sites where the manifest's own
  paths hold 7, and a non-plain id was accepted as a row and digest key.
- A script-level failure inside the namespace was classified
  `HarnessIncomplete`, whose message tells the operator to raise a value via
  [target].env — the wrong remedy for a missing `timeout`. The script now
  raises a reserved 96 from an EXIT trap covering the whole pre-exec region,
  and `observe` classifies it before the stream shape.
- `Prepend::repeat` was unbounded: a 60 GB pad killed probe-pin with SIGABRT
  134, outside the documented 0/1/2/101 contract, after a full control run.
  Capped at validation with `saturating_mul`.
- Seven fixtures named the committed `dogfood_block.md` as their output. This
  was live: running `projection.toml --write` spliced over it. Guarded by a
  census keyed on "driven by isolation_e2e.rs" rather than by two edits, with
  an instrument check that at least five fixtures are driven.
- Wildcard `_` arms over `Verdict` are gone, per the repo's exhaustive-match
  rule. Measured: with a third variant added, the exhaustive arms flag both
  sites; with `_ => None` they silently record an unmeasured probe's value.

The symlinked-root abort could NOT be reproduced: getcwd(2) answers
physically, so cargo's locate-project output is already resolved, through a
symlinked cwd and through a bind mount. Fixed anyway, because the comparison's
SHAPE was lexical while the surface's semantics are about what a path
resolves to — the same reason validate_paths was rewritten earlier in this
branch. Recorded in the code as not-reached-today hardening, not as a closed
live escape.

Nextest serialization was declined with the measurement: the suite is
#[ignore]d so CI never schedules it, and the Tilt venue runs libtest in one
process where the mutex holds. Adding config for a venue nothing uses would
be unmeasured ceremony. The reviewer's second half was valid — the comment
claimed a guarantee it does not universally provide, and now states its
measured scope.

Assisted-by: ClaudeCode:claude-opus-5
The manifest layer already rejects every marker-unsafe field a manifest can
supply (control characters, marker tags, in anchor/marker/projection text), and
all four injection strings from the review are refused at exit 2 before any
write. That coverage is field-shaped, though, and two producers reach a rendered
line without passing through a manifest field: an instrument's `--version`
output, and the `provenance` paths lifted from the target's own panic text.
Neither can be refused in advance.

So the guarantee moves to the write surface. `splice` re-runs `locate` over its
own spliced output and returns an abort rather than a block whose marker span
the next `check` could not find. Field coverage stays as the early, specific
refusal; the postcondition is what makes the property unconditional.

Assisted-by: ClaudeCode:claude-opus-5
@lgray

lgray commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

@matthewevans — addressed at 2a47fca62. Both remedies you named are implemented, because measuring the first exposed a residual only the second closes.

Your review is pinned to ce428ff47, which predates c1df7cb6c. Manifest-derived rendered fields are already refused there. I re-ran your exact strings through the built binary rather than reasoning about the diff — each aborts at exit 2, before any write:

manifest input outcome
anchor = "\nPROBE-PIN:END" (your string) exit 2 — carries the control character '\n'
anchor = "boom\nsecond line" exit 2 — same
marker = "PROBE\nPIN" exit 2
[[projection]] sentence = "PROBE-PIN:END {count}" exit 2 — contains the marker tag

That coverage is field-shaped, which is the failure mode this crate has already had to repair once: three review rounds each patched the field a reviewer named, and the next round found the same defect in a different field. The same reasoning applies to your finding. Two producers reach a rendered line without passing through a manifest field, so no advance validation covers them:

  • an instrument's --version output, rendered into the provenance line;
  • the provenance paths lifted from the target's own panic text.

So I took your second form as well. block::splice now re-runs locate over its own spliced output and returns an abort rather than a block whose marker span the next check could not find:

pub fn splice(text: &str, marker: &str, file: &Path, block: &str) -> Result<String, Abort> {
    let (begin, end) = locate(text, marker, file)?;
    let spliced = format!("{}{block}{}", &text[..begin], &text[end..]);
    locate(&spliced, marker, file)?;   // refuse what the next check could not find
    Ok(spliced)
}

Field validation stays as the early, specific refusal; the postcondition is what makes "run --write cannot produce an uncheckable block" unconditional instead of contingent on field coverage.

Regression testpure_logic::write_can_never_produce_a_block_its_own_check_cannot_locate, asserting both halves you asked for: manifest::validate_block_text refuses the injected marker, and splice refuses to return an unlocatable block.

Non-vacuity, measured rather than asserted: deleting the postcondition line makes the test fail at exit 101, and the panic names the injected second END

panicked at crates/probe-pin/tests/pure_logic.rs:1380:
a block carrying a second END must never be returned for writing:
"above\n// PROBE-PIN:BEGIN new\n// | p | m | pass | pass | // PROBE-PIN:END | — |\n// PROBE-PIN:END\nbelow\n"

The mutated source was restored and re-verified byte-identical with sha256sum -c. Worth recording that the test's first draft was wrong in a way the binary's exit code hid: it called manifest::validate(&m), which returns Ok for "\nPROBE-PIN:END" — the guard is validate_block_text, a separate function reached from main.rs. Retargeted at the function that actually refuses.

Gate battery at this head, post-rebase onto upstream/main cc7668062: cargo fmt --all --check 0 · cargo clippy --workspace --all-targets -D warnings 0 · cargo test -p probe-pin 0 (pure_logic 64 passed / 1 ignored) · --test isolation_e2e -- --ignored 0 (17 passed) · cargo probe-pin check …/dogfood.toml 0.

The splice postcondition only makes the guarantee unconditional while splice
is the sole route to the pinned file's bytes. That premise was stated in the
docs and in the review reply but never measured, and a second `fs::write` of
`output.file` added later would falsify all three with nothing failing.

Measured: exactly two write producers exist in `src/` — the scratch mutant,
which is never a rendered block, and the pinned file, whose content argument
is literally the `splice(..)?` call. The guard is inside the only write's
argument rather than merely on its path.

The comment-strip is disclosed as defensive rather than load-bearing, because
its DROP arm did not flip: this comment's first draft claimed a raw grep finds
a third hit in `manifest.rs`, but that mention is in backticks with no open
paren and never matched. Its value is measured against the input it exists for
instead — a doc comment quoting a call site, green with the strip and `found 3`
without.

Assisted-by: ClaudeCode:claude-opus-5
@lgray

lgray commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Follow-up at 9104cef64 — the premise under my previous comment is now measured rather than asserted.

I claimed the splice postcondition makes the guarantee "unconditional at the write surface". That is only true while splice is the sole route to the pinned file's bytes, and I had stated that without measuring it. Census of src/, counted on call sites rather than on the word:

producer writes routed through splice
mutate.rs:93 the mutant, into the scratch dir n/a — never a rendered block
main.rs:232 the pinned file yes — the content argument is block::splice(..)?

Two producers, no others. So the guard sits inside the only write's argument, not merely on its path. pure_logic::every_write_of_the_pinned_file_routes_through_splice pins that, so a second fs::write of output.file fails a test instead of quietly falsifying the sentence in the docs and in this thread.

Four arms, each flipping a different assertion — dropping splice from the write argument (exit 101, property), adding a third producer (exit 101, found 3), breaking the search patterns (exit 101, zero-census positive control).

The fourth arm did not flip, and I'd rather record that than drop it. The test strips comment lines before counting, and my first draft of its doc comment justified that by claiming a raw grep finds a third hit in manifest.rs. It doesn't: that mention is `fs::write` in backticks with no open paren, and every producer pattern requires the paren — I had carried the number over from an earlier grep with a different pattern and never re-measured under the instrument I shipped. Deleting the strip left the test green. It is now disclosed as defensive, with its value measured against the input it actually defends: a doc comment quoting a call site, green with the strip and found 3 without.

Gate battery at this head: fmt --check 0 · clippy --workspace --all-targets -D warnings 0 · cargo test -p probe-pin 0 (pure_logic 65 passed / 1 ignored) · --test isolation_e2e -- --ignored 0 (17 passed) · cargo probe-pin check …/dogfood.toml 0 · Gate A PASS head=9104cef64 base=cc7668062 (vacuous — 0 of 31 changed files are under crates/engine/src/parser; stated in the body as not-applicable-but-green).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
crates/probe-pin/src/block.rs (1)

381-394: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

The hand-written variant array defeats the compiler on a new Instrument.

Line 388 enumerates [Instrument::Toolchain, Instrument::AstGrep] by hand. Add a third variant to Instrument and this code still compiles. The new variant's // instrument <tool> = <version> line then resolves to None, instrument_lines drops it, and changed_instruments never reports it.

The consequences are both in the classification this module owns:

  • check reports DriftCause::Code for a run where only that instrument moved. The report on line 494 then tells the operator the block is stale.
  • write_refusal never fires for that instrument, so run --write re-stamps across the change it cannot attribute — the exact case lines 460-462 say it must refuse.

used_instruments on line 294 carries the same pair. Give Instrument one authoritative list and use it in both places, so a new variant is a compile error and not a silent hole.

♻️ Proposed direction
// in the module that defines `Instrument`
impl Instrument {
    /// Every variant, exhaustively. The `match` is what makes a new variant a compile error.
    pub const ALL: &'static [Instrument] = &[Instrument::Toolchain, Instrument::AstGrep];

    #[allow(dead_code)]
    fn exhaustiveness_guard(self) -> &'static str {
        // A new variant fails to compile here, which is the point.
        match self {
            Instrument::Toolchain => "toolchain",
            Instrument::AstGrep => "ast-grep",
        }
    }
}
-            [Instrument::Toolchain, Instrument::AstGrep]
-                .into_iter()
+            Instrument::ALL
+                .iter()
+                .copied()
                 .find(|i| i.tool() == name)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/src/block.rs` around lines 381 - 394, Define one
authoritative exhaustive Instrument list, with a match-based guard that requires
updating when variants are added, and replace the hand-written variant pairs in
both instrument_lines and used_instruments with that list. Preserve the existing
tool-name matching and ensure every Instrument variant is considered for
classification and write refusal.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/probe-pin/src/block.rs`:
- Around line 214-220: Update firing_cell to determine control status from
probe.mutations.is_empty(), matching mutation_cell, rather than from
Verdict::Pass { mounts_reached: 0 }; keep mounts_reached only for reporting
reached files and preserve the existing failure-anchor output.
- Around line 257-260: Update render’s probe/outcome handling so every declared
probe has the same explicit unmeasured representation as digest, rather than
silently skipping probes with no matching outcome. Prefer enforcing a complete
probe/outcome pairing at the render boundary, or abort clearly when a declared
probe lacks an outcome; ensure the rendered table cannot contain fewer rows than
manifest.probes.

In `@crates/probe-pin/src/manifest.rs`:
- Around line 567-571: Update the validation loop over positional_argv_values in
validate to reject [target].filter when its value is empty after trimming,
alongside the existing leading-dash operand check. Emit a manifest-validation
error for the blank filter while preserving the current rejection for values
beginning with '-'.
- Around line 623-628: Update mutation validation in
crates/probe-pin/src/manifest.rs at lines 623-628 and 122-126: in the existing
Mutation::Prepend arm, reject pads whose computed byte count is zero, covering
repeat = 0 and empty text; in the mutation loop’s Mutation::Replace arm, reject
an empty find value and reject find values equal to replace. These checks must
prevent zero-effect or non-targeted mutations while preserving the existing
size-cap validation.

In `@Tiltfile`:
- Around line 287-295: Prevent probe-pin-e2e and probe-pin-check from running
concurrently while sharing target/probe-pin. Update the local_resource
definitions for both resources to disable parallel execution, or assign
probe-pin-e2e a distinct CARGO_TARGET_DIR; preserve their existing commands and
dependency watches.

---

Nitpick comments:
In `@crates/probe-pin/src/block.rs`:
- Around line 381-394: Define one authoritative exhaustive Instrument list, with
a match-based guard that requires updating when variants are added, and replace
the hand-written variant pairs in both instrument_lines and used_instruments
with that list. Preserve the existing tool-name matching and ensure every
Instrument variant is considered for classification and write refusal.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a13c76eb-0cd7-4d37-b2bd-a4f04168145d

📥 Commits

Reviewing files that changed from the base of the PR and between f186aef and 2a47fca.

📒 Files selected for processing (18)
  • Tiltfile
  • crates/probe-pin/src/block.rs
  • crates/probe-pin/src/isolate.rs
  • crates/probe-pin/src/lib.rs
  • crates/probe-pin/src/main.rs
  • crates/probe-pin/src/manifest.rs
  • crates/probe-pin/src/target.rs
  • crates/probe-pin/src/verdict.rs
  • crates/probe-pin/tests/fixtures/collide.toml
  • crates/probe-pin/tests/fixtures/compiled.toml
  • crates/probe-pin/tests/fixtures/control_expect_fail.toml
  • crates/probe-pin/tests/fixtures/control_fails.toml
  • crates/probe-pin/tests/fixtures/projection.toml
  • crates/probe-pin/tests/fixtures/treewrite.toml
  • crates/probe-pin/tests/fixtures/unsorted_ids.toml
  • crates/probe-pin/tests/isolation_e2e.rs
  • crates/probe-pin/tests/pure_logic.rs
  • docs/probe-pin.md
🚧 Files skipped from review as they are similar to previous changes (10)
  • crates/probe-pin/tests/fixtures/projection.toml
  • crates/probe-pin/tests/fixtures/collide.toml
  • crates/probe-pin/tests/fixtures/control_fails.toml
  • crates/probe-pin/tests/fixtures/control_expect_fail.toml
  • crates/probe-pin/tests/fixtures/treewrite.toml
  • crates/probe-pin/src/verdict.rs
  • crates/probe-pin/src/main.rs
  • crates/probe-pin/src/isolate.rs
  • crates/probe-pin/src/lib.rs
  • crates/probe-pin/tests/fixtures/unsorted_ids.toml

Comment thread crates/probe-pin/src/block.rs
Comment thread crates/probe-pin/src/block.rs
Comment thread crates/probe-pin/src/manifest.rs
Comment on lines +623 to +628
if let Mutation::Prepend { text, repeat, .. } = mutation {
let bytes = text.len().saturating_mul(*repeat as usize);
if bytes > MAX_PREPEND_BYTES {
bail!("probe-pin: {} mutation[{index}] prepends {} bytes ({} × repeat {repeat}), over probe-pin's {MAX_PREPEND_BYTES}-byte cap. The pad is materialized in memory, written to the scratch dir, bind-mounted and then READ by the target, so this is a mutant no probe can measure — and past the allocator's limit it is not even a refusal: probe-pin aborts on allocation failure (measured: 60,129,542,130 bytes requested, SIGABRT). The shipping pads in this repository are ~2.8 kB. Lower `repeat`, or shorten `text`. Aborting.", p.id, bytes, text.len());
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

validate checks mutation paths and the Prepend upper bound, but never checks that a mutation payload expresses a targeted, non-identity edit. Both variants therefore admit a mutant that is byte-identical to the pristine file, which is the harm the empty-file-list refusal at Line 613 names: the probe measures the unmodified tree and the block renders that verdict as a mutant's.

  • crates/probe-pin/src/manifest.rs#L623-L628: in the same arm that checks MAX_PREPEND_BYTES, refuse a pad of 0 bytes, so repeat = 0 and text = "" are rejected.
  • crates/probe-pin/src/manifest.rs#L122-L126: add a Mutation::Replace arm to the mutation loop that refuses an empty find (it matches at every position and rewrites the whole file) and refuses find == replace (an identity edit).
📍 Affects 1 file
  • crates/probe-pin/src/manifest.rs#L623-L628 (this comment)
  • crates/probe-pin/src/manifest.rs#L122-L126
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/probe-pin/src/manifest.rs` around lines 623 - 628, Update mutation
validation in crates/probe-pin/src/manifest.rs at lines 623-628 and 122-126: in
the existing Mutation::Prepend arm, reject pads whose computed byte count is
zero, covering repeat = 0 and empty text; in the mutation loop’s
Mutation::Replace arm, reject an empty find value and reject find values equal
to replace. These checks must prevent zero-effect or non-targeted mutations
while preserving the existing size-cap validation.

Comment thread Tiltfile
…ilter

Four findings from review, each verified against a run before it was touched.

A row now says what a probe IS. `firing_cell` decided "this is the control"
from `mounts_reached == 0` while `mutation_cell` decided the same fact from
`probe.mutations.is_empty()` — two discriminators for one concept, agreeing
only because `validate` refuses an empty mutation file list. Any path yielding
a mutation probe with no mount would have rendered `(control; no mounts)` for
it, and the digest pins the same count, so nothing would disagree. Rendered
output is byte-identical (the committed block and `check` both unchanged).

An outcome-less probe gets a visible `unmeasured` row instead of being skipped,
matching what `digest` already records for the same input. Skipping made the
block show N-1 rows for N declared probes while `check` stayed CLEAN, because
the committed copy omitted the same row. An `unreachable!` here was tried and
was wrong: `render` is public and tests legitimately pass `&[]` outcomes, so it
panicked on correct use — the invariant belongs to `main`'s call order.

A blank `[target].filter` is refused. Measured: with `filter_match =
"substring"` the run widens to the whole target binary, caught only because a
widened test happened to fail, which reports as a broken control rather than as
the manifest defect it is; with `exact` it matches nothing and the execution
floor blames the target for a typo here.

`probe-pin-e2e` gets its own `CARGO_TARGET_DIR`. It shared one with
`probe-pin-check` while both watch `crates/probe-pin/` and both allow parallel
runs, so one edit could drive two cargos into the same dir. The contention is
cross-process, so the in-process mutex in the suite does not span it, and the
flake recorded there has exactly that mechanism.

Not changed: identity mutations are already refused. `find == replace`,
`repeat = 0` and `text = ""` each abort at exit 2 on "the materialized mutant is
byte-identical to the original" — one guard after materialization, covering the
class regardless of mutation kind.

Assisted-by: ClaudeCode:claude-opus-5
@lgray

lgray commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

@coderabbitai — all five verified against a run before anything was edited. Four fixed at 815aaf279; one declined with measurement.

Declined: identity mutations are already refused. The finding is right that validate does not check mutation payloads. The refusal is not in validate — it is in mutate.rs, after materialization, where it compares the mutant against the original. Every case named, run through the shipping binary:

manifest result
find == replace exit 2 — the materialized mutant is byte-identical to the original
prepend repeat = 0 exit 2 — same guard
prepend text = "" exit 2 — same guard
replace find = "" exit 2 — the occurrence gate

One guard covers the class regardless of mutation kind, including kinds not yet written. The proposed fix adds per-variant checks in validate — narrower, and enumerated per case; that is the field-shaped pattern this crate has already had to repair twice, so I'd rather not reintroduce it one surface up.

Fixed:

  • firing_cell now keys on probe identity. Correct diagnosis: mounts_reached == 0 and probe.mutations.is_empty() were two discriminators for one concept. Latent today — validate refuses an empty mutation file list, so mounts.len() >= 1 — but the digest pins the same count and so would have agreed with a lying row. Rendered output is byte-identical: the committed block is unchanged in git, check exits 0.
  • A blank [target].filter is refused, next to the operand check, as the empty end of the same positive-shape rule. Measured with substring: the run widens to the whole binary and exits 2 — but as "the control probe FAILED … the instrument is broken", because a widened test happened to fail. Nothing looked at the filter. Had the widened suite passed, every row would have rendered green.
  • probe-pin-e2e gets its own CARGO_TARGET_DIR. Confirmed: both resources watch crates/probe-pin/, both allow_parallel, both drove cargo into target/probe-pin.

On the missing-row finding — the suggested fix was wrong, and my test suite caught it. I applied unreachable! as proposed. It panicked in projection_surface_gets_the_producer_checks_its_siblings_get, which calls block::render(&m, .., &[], ..) deliberately to inspect projection sentences. render is public and that input is legitimate; the invariant belongs to main's call order, not to this function, so enforcing it there fires on correct use.

The finding underneath it was still right, so render now emits a visible unmeasured row — the same thing digest already records for that input (rc = -1, verdict = "unmeasured"). That closes the disagreement without a panic, and a_probe_with_no_outcome_gets_a_visible_unmeasured_row pins it: restoring the skip fails at exit 101 with "the table dropped a declared probe".

A second existing test listed "" among benign filter values. Rather than delete the case, it now asserts which guard refuses it — the naming rule, not the leading-- operand rule — so the benign list still proves the operand rule is discriminating rather than blanket-rejecting the field.

Each fix has a DROP arm flipping a different assertion: reverting firing_cell → 101 "a probe WITH mutations rendered as the control"; dropping the filter refusal → 101; restoring the silent skip → 101. Mutated sources restored and re-verified with sha256sum -c.

Battery at this head: fmt --check 0 · clippy --workspace --all-targets -D warnings 0 · cargo test -p probe-pin 0 (pure_logic 68 passed / 1 ignored) · --test isolation_e2e -- --ignored 0 (17 passed) · cargo probe-pin check …/dogfood.toml 0.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@lgray, thanks for the detailed verification.

I withdraw the identity-mutation finding. The materialization guard rejects byte-identical mutants for all mutation kinds. This is broader than variant-specific validation and prevents no-op evidence from being generated.

The four fixes address the remaining findings:

  • firing_cell now uses probe identity.
  • Validation rejects a blank [target].filter.
  • probe-pin-e2e uses a separate CARGO_TARGET_DIR.
  • render shows an unmeasured row instead of silently omitting a declared probe.

The regression and DROP-arm results support each change.


✏️ Learnings added
Learnt from: lgray
URL: https://github.com/phase-rs/phase/pull/7315

Timestamp: 2026-08-13T00:37:55.184Z
Learning: In the Rust `probe-pin` crate, `crates/probe-pin/src/mutate.rs` rejects a materialized mutant when its bytes are identical to the original file. This common guard rejects identity mutations across mutation kinds, including `replace` where `find == replace`, `prepend` with zero repetition or empty text, and future mutation kinds that produce no file change.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

An independent reviewer ran this census with a wider producer set and got the
same answer. The agreement was luck: `fs::copy` and `fs::rename` appear in no
`src/` module today, so both instruments returned two — but a future
`fs::copy(tmp, out_path)` would have written the pinned file while this test
still reported "exactly 2", and the splice postcondition's sole-route premise
would have been false with nothing failing.

The new patterns are live rather than decorative: injecting an `fs::copy` into
`block.rs` now fails the census at `found 3`, naming the module. Before the
widening that injection was invisible to it.

Assisted-by: ClaudeCode:claude-opus-5
@matthewevans matthewevans self-assigned this Aug 13, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after current-head review: the block-marker injection path is closed at manifest validation and final splice verification; complete CI is green.

@matthewevans matthewevans added enhancement New feature or request and removed bug Bug fix labels Aug 13, 2026
@matthewevans
matthewevans added this pull request to the merge queue Aug 13, 2026
@matthewevans matthewevans removed their assignment Aug 13, 2026
Merged via the queue into phase-rs:main with commit 552cf8f Aug 13, 2026
18 checks passed
JacobWoodson added a commit to JacobWoodson/phase that referenced this pull request Aug 13, 2026
Six findings on ef863dd, all valid.

- ability_rw `legacy_effect`: `RevealChosenNumbers` answered `false` without
  traversing the `PlayerFilter` it carries, unlike its `SwapChosenLabels`
  neighbour, which carries none. Now delegates to `legacy_player_filter`, which
  detects `TriggeringPlayer` and recurses through the nested `ControlsCount` /
  `PlayerAttribute` / `AllExcept` forms a future reveal could name.

- `Effect::RevealChosenNumbers` doc referenced `GameState::revealed_chosen_numbers`
  -- a field that was tried and abandoned when the stack-budget guard rejected it,
  so the reference described a mechanism that does not exist. Replaced with the
  real one: `Player::reveal_chosen_number` swapping `ChosenAttribute::Number` for
  `RevealedNumber`, which `game::visibility` redacts on.

Three test-strength fixes, two of them the vacuous-negative anti-pattern the
repo documents:

- The subject matrix destructured `AllPlayers { aggregate, .. }` and discarded
  `exclude`. Now asserted. It matters on the opponent relation: `relation`
  narrows WHO IS AFFECTED, but an `exclude` would also narrow WHAT IS COMPARED
  and hit an opponent whose number the controller had beaten.

- The anaphor case asserted only `is_ok()`, which an implementation that ignored
  the parameter and hardcoded one extremum would satisfy. Now asserts the
  returned pair across BOTH extrema, so the binding is shown to track the
  argument rather than coincide with a default.

- The provenance sweep's invariant is an implication, so it held for any card
  producing no reference at all -- if the grammar stopped firing entirely, all
  eight cases would pass while proving nothing. Same for the Custodi guard, which
  passed whenever the tap clause vanished, because the negative predicate is also
  false for an `Unimplemented` parse. Both now carry positive reach guards.

The sweep's guard immediately caught a wrong assumption: it was written expecting
Menacing Ogre AND Itazura to read the extremum. Only Menacing Ogre does. Itazura
creates the number choice but its "Choose an opponent with the highest number"
does not bind to it -- the restriction lowers without a `PlayerChosenNumber`
threshold. A real partial-support gap, not a regression (on main the card died at
`Unimplemented { secretly }`). The guard now pins the reader set BY NAME so
closing that gap fails the assertion and forces a deliberate update.

Not taken: CodeRabbit reported `Player::chosen_number()` reads only `Number`,
citing `game_object.rs:2832`. That is `impl GameObject` -- the object axis
(Talion's persisted number). `candidate_player_scalar` takes `&Player` and
resolves to `types/player.rs`, which reads `Number | RevealedNumber`;
`revealing_a_chosen_number_preserves_value_and_tolerates_non_choosers` asserts
the value survives the reveal.

Verification on the merged tree: `cargo test -p phase-engine` exit 0 (18917 lib +
4859 integration), `cargo clippy --workspace --all-targets --exclude probe-pin`
exit 0. probe-pin is excluded because phase-rs#7315 added it using Unix-only
`ExitStatusExt::signal`, which does not build on Windows; `git diff origin/main
HEAD -- crates/probe-pin` is empty, so this branch does not touch it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JacobWoodson added a commit to JacobWoodson/phase that referenced this pull request Aug 13, 2026
Addresses the three blockers on the previous head.

1. "a number 0 or greater" was capped at 20

The parser lowered every bare number choice to NumberRange { min: 0, max: 20 },
so 21 was rejected outright. On Wheel of Misfortune the magnitude of the number
IS the decision, so an invented ceiling made a legal choice illegal.

NumberRange's max is now Option<u32>, and None means what the rules mean: no
maximum. Bounded card text ("a number between 1 and 5") keeps Some and
serializes byte-identically -- only the unbounded form omits the key. The
between-form now DECLINES rather than substituting a ceiling when the upper
token is missing, so a malformed phrase strict-fails instead of inventing a
bound.

The stored value widens u8 -> u32 across ChosenAttribute::Number /
RevealedNumber and ChoiceValue::Number; u8 could not hold 256, let alone a real
bid. The accepted domain is bounded at i32::MAX, which is not a UI cap but the
engine's own arithmetic domain -- every quantity resolves through i32, and
damage and life totals are i32, so a number beyond it could not be dealt or
compared. Within that domain every value the rules permit is accepted.

End to end: an unbounded range enumerates nothing (compute_options returns
empty) and routes through options_supplied_by_player -- the same free-entry path
CardName already used. ChoiceType::accepts_free_entry_answer is the single
validation authority, shared by the ChooseOption answer seam and the AI's
legal-action enumeration so the two cannot disagree about what is legal. The AI
samples a life-total-anchored ladder for a domain it cannot enumerate, filtered
through that same authority. The client renders a numeric input instead of a
button grid when max is absent.

New regression: a_number_past_the_old_ceiling_is_choosable_and_deals_that_much_damage
bids 40 and 21 -- both past the old ceiling -- and asserts 40 is accepted,
stored, folded as the cross-player maximum, and dealt as 40 damage. The existing
three-seat test structurally could not detect this: it only ever chooses 1 and 4.

2. Persistence missed chosen-number reads in conditions

definition_reads_player_chosen_number walked player_scope, effect quantities,
DamageEachPlayer and sub/else links but never AbilityDefinition::condition, so a
QuantityCheck reading PlayerChosenNumber left the upstream choice
non-persistent and the answer was cleared before the condition resolved. Added a
recursive walker through QuantityCheck / And / Or / Not / ConditionInstead, plus
a regression that buries the reference under Not(And(...)) and carries a control
proving it measures the condition walk rather than blanket promotion.

3. Reveal grammar was prefix-accepting and active-voice-blind

parse_reveal_chosen_numbers_clause matched only tag("reveal ") and accepted a
prefix, discarding any remainder. It now requires complete-clause consumption
and covers third person with the "s" as its own opt axis rather than duplicated
tags. The anti-swallow test asserts the surviving tail rather than the head's
shape: the clause splitter separates a trailing instruction before this grammar
sees it, so asserting "the head is not a reveal" would test the splitter's
boundary choice instead of the property that matters.

Two things caught by the repo's own instruments while doing this:

- The extended serde round-trip test caught that an unbounded range serialized
  as "max": null rather than omitting the key. ChoiceType has a hand-written
  Serialize, so the skip_serializing_if on the ChoiceTypeData deserialize mirror
  never applied to the write path; it is now mirrored by hand.
- The committed-guess placeholder sentinel was min: 0, max: 0, which would have
  become min: 0, max: None -- the exact shape of a genuine unbounded choice,
  classifying every real one as an unfilled placeholder. The sentinel is now
  Some(0), a range containing only 0 that no card text produces.

Verification on the merged tree: cargo test -p phase-engine exit 0, cargo test
-p phase-ai exit 0, cargo clippy --workspace --all-targets --exclude probe-pin
exit 0, and the frontend suite including the seven-locale key-parity gate.
probe-pin is excluded because phase-rs#7315 added it using Unix-only
ExitStatusExt::signal, which does not build on Windows; this branch does not
touch it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cuinhellcat pushed a commit to cuinhellcat/phase that referenced this pull request Aug 14, 2026
…hase-rs#7266)

* Implement Wheel of Misfortune (secret simultaneous number choices)

"Each player secretly chooses a number 0 or greater, then all players
reveal those numbers simultaneously and determine the highest and lowest
numbers revealed this way. ~ deals damage equal to the highest number to
each player who chose that number. Each player who didn't choose the
lowest number discards their hand, then draws seven cards."

Every clause of this card keys on a CROSS-PLAYER extremum of per-player
choices, which the engine had no way to express: the whole sentence
lowered to four consecutive Effect::Unimplemented links. Built for the
class (Menacing Ogre, Life at Stake), not the card.

Engine

- QuantityRef::PlayerChosenNumber { player: PlayerScope } -- a 6th member
  of the per-player-scalar family (HandSize / LifeTotal / GraveyardSize /
  PlayerCounter / ...), so AllPlayers { Max | Min } IS "the highest /
  lowest number" and ScopedPlayer is the per-candidate read. It stays
  separate from the object-axis ChosenNumber (CR 607.2d, read off the
  source's LKI) because the two have different subjects and different
  runtime resolvers. No new PlayerFilter variant: "who chose the highest
  number" reuses the parameterized PlayerAttribute, and "didn't choose the
  lowest" is just Comparator::NE.
- resolve_per_player_scalar_opt folds the aggregate scopes over only the
  players that HAVE the scalar, so a card whose choosers are a subset of
  the table (Life at Stake) does not read 0 as its minimum.
- record_player_chosen_number records a chosen number on the chooser
  ADDITIVELY, leaving every existing source binding intact -- deliberately
  not a reroute, because ResolvedAbility::scoped_player is set for a plain
  triggered ability as well as for a real fan-out iteration and so cannot
  gate one (measured on The Toymaker's Trap).
- The ledger is cleared at every top-level resolution entry alongside
  last_vote_ballots; Player::chosen_attributes is otherwise durable, so
  without it a later card would fold in bystanders' stale numbers.
- game::visibility keeps a player's ChosenAttribute::Number private to
  that player. Privacy is a property of the field, not of the current
  prompt, so no call path can open a window where a live secret leaks.

Parser

- "secretly" joins the existing leading-adverb peel: it is a visibility
  property, not an effect, so the choice parses like an open one.
- parse_chosen_number_restriction composes polarity x verb form x
  extremum, plus the "that number" anaphor -- bound structurally to the
  clause's already-parsed amount rather than re-matching Oracle text.
- "the highest / lowest number" as a quantity, guarded against the plural
  bookkeeping noun and against the "number OF <things>" counting phrase.
- The reveal sentence lowers to Effect::NoOp: revealing information
  changes no game object, and the extrema are computed on demand.
- A post-pass persists a number choice iff a later clause in the assembled
  chain reads it back, enforcing structurally the rule the persist
  decision already claimed to follow.

Verification: full cargo test -p phase-engine green (18857 lib + 4815
integration). The new integration test drives the real parse -> cast ->
resolution pipeline over three seats (P0/P1 tie at 4, P2 low at 1) and
pins that the damage hits BOTH tied players for exactly 4, that P2 takes
none, and that the wheel skips P2 alone.

Not included: crates/engine/tests/fixtures/integration_cards.json.gz still
holds the pre-change parse of Life at Stake. Regenerating it needs
client/public/card-data.json, which requires a full MTGJSON download plus
an export run. No test loads that card from the fixture (every reference
parses the Oracle text live) and the CI check is presence-only, so this is
latent rather than breaking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address review: bind the chosen-number quantity, model the reveal, i18n

Three blockers from @matthewevans on phase-rs#7266.

1. "the highest/lowest number" was parsed by WORDING alone

The extremum reference was registered in the context-free
`parse_quantity_ref` alt with only a " of " guard, so it matched any card
containing the phrase. The CI parse-diff caught the consequence: Custodi
Peacekeeper's "power less than or equal to the highest number YOU NOTED
for cards named Custodi Peacekeeper" -- a draft-time noted value with no
choice anywhere on the card -- had its Tap target silently rewritten to
"power <= secretly chosen number (max of all players)".

The combinator is now unregistered from the context-free grammar and
reachable only from a provenance-gated arm in
`parse_cda_quantity_with_context`, which fires only when
`ParseContext::pending_choice_type` proves a preceding `NumberRange`
choice in the same ability -- the same gate `try_parse_guess_clause`
already applies to "guesses which number you chose".

Re-checked every card in the parse-diff rather than only the reported one.
All twelve are members of the "each player secretly ..." class that
previously died at `Unimplemented { secretly }` (Círdan the Shipwright,
Mob Verdict, Trap the Trespassers, Mana Conference, Call to the Void,
Prisoner's Dilemma, Itazura, Menacing Ogre) -- unlocks, not
reinterpretations. That check is now a test rather than an inspection:
`secret_number_provenance_invariant_holds_across_the_class` asserts a card
may READ a secretly-chosen number only if it also CREATES one, over the
six real class members plus two controls (Custodi Peacekeeper's noted
number, and a pure counting phrase). Wording-matched parsing passes the
six and fails both controls; provenance-bound parsing passes all eight.

2. The reveal never published the values

The reveal clause lowered to `Effect::NoOp` while visibility redacted every
other player's number unconditionally, so the engine kept information
secret after the instruction that makes it public.

The reveal is now a typed transition on the player's own attribute:
`ChosenAttribute::Number` (private) -> `RevealedNumber` (public),
performed by `Effect::RevealChosenNumbers { players }`. Visibility redacts
on the KIND, so a value is visible exactly when the game has published it
and no call path can open a leak window. `Player::chosen_number` reads both
variants, because revealing changes who may see a number, never what it is.

Modeled on the player attribute rather than a `GameState` field because the
stack-budget guard rejected the field -- correctly; `Player` is heap-backed
and this is per-player data. NOT folded into the `Reveal`/`RevealTop`
family: CR 701.20a defines revealing a CARD, and those effects are
parameterized over zone/count/card-filter, none of which a committed number
has. `GameEvent::ChosenNumbersRevealed` carries the whole simultaneous set
in one event so the log cannot imply an ordering CR 101.4 does not have.

The integration test now proves both directions: each chooser is checked
mid-fan-out and cannot see the earlier seats' answers, and after resolution
all three players see all three revealed numbers.

3. Frontend labels bypassed the i18n boundary

Routed through `i18n.t()` (the `import i18n from "../i18n"` pattern
`game/dispatch.ts` uses), with a `quantityRef` section added to all seven
locale catalogs and `formatCost` coverage for Max, Min and the scoped
fallback. The surrounding labels in `costLabel.ts` are pre-existing raw
English and are left for a separate pass.

Also restores three parser tests that a stray `git checkout` reverted out
of 6f4903a before it was committed, and re-pins the CR 603.5 prompt census
with the measurement for this round's line shifts.

Verification: `cargo test -p phase-engine` exit 0 (18864 lib + 4815
integration), `cargo clippy --workspace --all-targets` clean, 95 frontend
tests including the seven-locale key-parity gate.

Note: the six non-English `quantityRef` strings were written by me, not a
native speaker, and should get a translation pass before merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address CodeRabbit review on the merged head

Six findings on ef863dd, all valid.

- ability_rw `legacy_effect`: `RevealChosenNumbers` answered `false` without
  traversing the `PlayerFilter` it carries, unlike its `SwapChosenLabels`
  neighbour, which carries none. Now delegates to `legacy_player_filter`, which
  detects `TriggeringPlayer` and recurses through the nested `ControlsCount` /
  `PlayerAttribute` / `AllExcept` forms a future reveal could name.

- `Effect::RevealChosenNumbers` doc referenced `GameState::revealed_chosen_numbers`
  -- a field that was tried and abandoned when the stack-budget guard rejected it,
  so the reference described a mechanism that does not exist. Replaced with the
  real one: `Player::reveal_chosen_number` swapping `ChosenAttribute::Number` for
  `RevealedNumber`, which `game::visibility` redacts on.

Three test-strength fixes, two of them the vacuous-negative anti-pattern the
repo documents:

- The subject matrix destructured `AllPlayers { aggregate, .. }` and discarded
  `exclude`. Now asserted. It matters on the opponent relation: `relation`
  narrows WHO IS AFFECTED, but an `exclude` would also narrow WHAT IS COMPARED
  and hit an opponent whose number the controller had beaten.

- The anaphor case asserted only `is_ok()`, which an implementation that ignored
  the parameter and hardcoded one extremum would satisfy. Now asserts the
  returned pair across BOTH extrema, so the binding is shown to track the
  argument rather than coincide with a default.

- The provenance sweep's invariant is an implication, so it held for any card
  producing no reference at all -- if the grammar stopped firing entirely, all
  eight cases would pass while proving nothing. Same for the Custodi guard, which
  passed whenever the tap clause vanished, because the negative predicate is also
  false for an `Unimplemented` parse. Both now carry positive reach guards.

The sweep's guard immediately caught a wrong assumption: it was written expecting
Menacing Ogre AND Itazura to read the extremum. Only Menacing Ogre does. Itazura
creates the number choice but its "Choose an opponent with the highest number"
does not bind to it -- the restriction lowers without a `PlayerChosenNumber`
threshold. A real partial-support gap, not a regression (on main the card died at
`Unimplemented { secretly }`). The guard now pins the reader set BY NAME so
closing that gap fails the assertion and forces a deliberate update.

Not taken: CodeRabbit reported `Player::chosen_number()` reads only `Number`,
citing `game_object.rs:2832`. That is `impl GameObject` -- the object axis
(Talion's persisted number). `candidate_player_scalar` takes `&Player` and
resolves to `types/player.rs`, which reads `Number | RevealedNumber`;
`revealing_a_chosen_number_preserves_value_and_tolerates_non_choosers` asserts
the value survives the reveal.

Verification on the merged tree: `cargo test -p phase-engine` exit 0 (18917 lib +
4859 integration), `cargo clippy --workspace --all-targets --exclude probe-pin`
exit 0. probe-pin is excluded because phase-rs#7315 added it using Unix-only
`ExitStatusExt::signal`, which does not build on Windows; `git diff origin/main
HEAD -- crates/probe-pin` is empty, so this branch does not touch it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Make secret-number choices unbounded, per CR 107.1a/b

Addresses the three blockers on the previous head.

1. "a number 0 or greater" was capped at 20

The parser lowered every bare number choice to NumberRange { min: 0, max: 20 },
so 21 was rejected outright. On Wheel of Misfortune the magnitude of the number
IS the decision, so an invented ceiling made a legal choice illegal.

NumberRange's max is now Option<u32>, and None means what the rules mean: no
maximum. Bounded card text ("a number between 1 and 5") keeps Some and
serializes byte-identically -- only the unbounded form omits the key. The
between-form now DECLINES rather than substituting a ceiling when the upper
token is missing, so a malformed phrase strict-fails instead of inventing a
bound.

The stored value widens u8 -> u32 across ChosenAttribute::Number /
RevealedNumber and ChoiceValue::Number; u8 could not hold 256, let alone a real
bid. The accepted domain is bounded at i32::MAX, which is not a UI cap but the
engine's own arithmetic domain -- every quantity resolves through i32, and
damage and life totals are i32, so a number beyond it could not be dealt or
compared. Within that domain every value the rules permit is accepted.

End to end: an unbounded range enumerates nothing (compute_options returns
empty) and routes through options_supplied_by_player -- the same free-entry path
CardName already used. ChoiceType::accepts_free_entry_answer is the single
validation authority, shared by the ChooseOption answer seam and the AI's
legal-action enumeration so the two cannot disagree about what is legal. The AI
samples a life-total-anchored ladder for a domain it cannot enumerate, filtered
through that same authority. The client renders a numeric input instead of a
button grid when max is absent.

New regression: a_number_past_the_old_ceiling_is_choosable_and_deals_that_much_damage
bids 40 and 21 -- both past the old ceiling -- and asserts 40 is accepted,
stored, folded as the cross-player maximum, and dealt as 40 damage. The existing
three-seat test structurally could not detect this: it only ever chooses 1 and 4.

2. Persistence missed chosen-number reads in conditions

definition_reads_player_chosen_number walked player_scope, effect quantities,
DamageEachPlayer and sub/else links but never AbilityDefinition::condition, so a
QuantityCheck reading PlayerChosenNumber left the upstream choice
non-persistent and the answer was cleared before the condition resolved. Added a
recursive walker through QuantityCheck / And / Or / Not / ConditionInstead, plus
a regression that buries the reference under Not(And(...)) and carries a control
proving it measures the condition walk rather than blanket promotion.

3. Reveal grammar was prefix-accepting and active-voice-blind

parse_reveal_chosen_numbers_clause matched only tag("reveal ") and accepted a
prefix, discarding any remainder. It now requires complete-clause consumption
and covers third person with the "s" as its own opt axis rather than duplicated
tags. The anti-swallow test asserts the surviving tail rather than the head's
shape: the clause splitter separates a trailing instruction before this grammar
sees it, so asserting "the head is not a reveal" would test the splitter's
boundary choice instead of the property that matters.

Two things caught by the repo's own instruments while doing this:

- The extended serde round-trip test caught that an unbounded range serialized
  as "max": null rather than omitting the key. ChoiceType has a hand-written
  Serialize, so the skip_serializing_if on the ChoiceTypeData deserialize mirror
  never applied to the write path; it is now mirrored by hand.
- The committed-guess placeholder sentinel was min: 0, max: 0, which would have
  become min: 0, max: None -- the exact shape of a genuine unbounded choice,
  classifying every real one as an unfilled placeholder. The sentinel is now
  Some(0), a range containing only 0 that no card text produces.

Verification on the merged tree: cargo test -p phase-engine exit 0, cargo test
-p phase-ai exit 0, cargo clippy --workspace --all-targets --exclude probe-pin
exit 0, and the frontend suite including the seven-locale key-parity gate.
probe-pin is excluded because phase-rs#7315 added it using Unix-only
ExitStatusExt::signal, which does not build on Windows; this branch does not
touch it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Bind the chosen-number anaphors and publish the free-entry contract

Addresses both blockers of the 2026-08-13T17:08 review.

Blocker 1 — "Choose an opponent with the highest number. ~ deals that
much damage to them." (Itazura, Lingering Wick) is now correct end to
end, not just at the selection.

  * The selection restriction binds through the existing
    ChoiceType::Opponent { restriction } seam, gated on the chunk-threaded
    pending_choice_type so the phrase only means a secretly-chosen number
    where this ability made one.
  * "Them" resolves to the chosen player. resolve_they_pronoun already
    had that arm; the damage-recipient resolver did not, so both now go
    through one authority, subject::chosen_player_anaphor_filter.
  * "That much" had no antecedent. EventContextAmount means "the amount
    the surrounding event supplies" and a resolving spell supplies none,
    so the instruction silently dealt 0. assembly::bind_chosen_number_anaphor
    rebinds it on the assembled chain, and only where the binding is
    provable: the recipient anaphor names a Choose(Player) clause by
    index, and that clause's own restriction says which extremum it
    selected by. No restriction, a negated restriction, or an index that
    does not line up all decline.
  * QuantityExpr::rebind_event_context_amount_to_previous_effect is now
    parameterized by antecedent rather than gaining a sibling method.

Blocker 2 — number-entry presentation moves out of the display layer.

  * ChoiceType::free_entry is the single definition of a typed answer's
    domain. accepts_free_entry_answer validates against it, and
    WaitingFor::NamedChoice publishes it, so there is no second statement
    of the numeric domain to drift from.
  * NamedChoiceModal no longer decodes the serialized ChoiceType or
    hard-codes 2147483647; it renders the published contract and bounds
    its input by the same values the engine enforces.

Tests

  * chosen_number_opponent_restriction: the damage assertions that
    previously documented the gap now pass, for the unique-highest and
    tied cases.
  * named_choice_free_entry_contract (new): the projected prompt carries
    the contract, it survives JSON with readable bounds, and the
    published maximum is exactly the enforced one — accepted at the
    bound, rejected one past it.
  * that_much_damage_to_them_binds_only_to_a_provable_chosen_number:
    pins both declines, with a non-vacuity guard that the damage clause
    still parses.
  * NamedChoiceModal.test.tsx: the modal's bound comes from the contract
    (a max of 99 rejects 500 and accepts 99), and an enumerated choice
    keeps its grid.

A wildcard in the ChooseOption dispatch arm is load-bearing and now says
so: binding free_entry to a literal there makes every free-entry answer
fall through to "action not allowed".

Verified: cargo test -p phase-engine (23927 pass, 0 fail), cargo clippy
--workspace --all-targets -D warnings, tsc -b --force, vitest (2714 pass).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Match the number prompt to each locale's own numeric-entry phrasing

The six translations of `namedChoice.numberSubtitle` were calques: I wrote
"Enter a number {{min}} or greater" and carried the English appositive
across word for word, into five languages that do not form a lower bound
that way. Each locale already had a translator-authored sibling for the
same control -- `mana.amountOutOfRange`, "Enter an integer between {{min}}
and {{max}}" -- so there was an established house phrasing to match rather
than invent against.

Each subtitle now takes the verb and the "whole number" noun from its own
locale's `mana.amountOutOfRange`, and states the lower bound the way that
language states one. Polish uses "nie mniejszą niż" rather than "równą",
because "równy" governs the dative and a bare interpolated digit cannot
supply it. English gains the same "whole number" noun; the input accepts
only digits and the engine parses u32, so "number" was understating it.

Adds a locale parity test. The suite renders in English only
(`test-setup.ts` loads `en`), so a key added to `en` and forgotten
elsewhere, or a placeholder dropped in translation, produces no failing
test -- it produces a raw key or a missing value in front of a player who
does not read English. The placeholder half is the one that catches real
damage: a translation that drops {{min}} still reads as fluent prose.

Key parity across all seven locales is already clean. The placeholder
check found one pre-existing defect, listed as a known gap with its
reason: draft `intro.quick.step1` hard-codes "3 packs of 14 cards" in all
six translations instead of interpolating, so a non-default draft shows
wrong numbers to those players. That is not this branch's to fix, and the
list has its own staleness check so the entry cannot outlive the bug.

Verified discriminating: dropping {{min}} from the German subtitle fails
the parity test naming the exact key. tsc -b --force clean; vitest 2811
pass (98 new).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(PR-7266): use nom for chosen-number restriction

Use the existing nom tag parser to consume the required separator before both chosen-number restriction paths, satisfying the parser combinator gate without changing the accepted grammar.

Co-authored-by: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants