feat(probe-pin): probe-manifest runner that pins comment claims to measured evidence - #7315
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughThe PR adds the ChangesProbe-pin execution and validation
Estimated code review effort: 5 (Critical) | ~120 minutes Mergeability Score: 🟠 High · up to 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
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (5)
crates/probe-pin/src/mutate.rs (1)
62-75: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winBound
Prepend::repeatat validation time.
text.repeat(*repeat as usize)allocatestext.len() * repeatbytes. The schema accepts anyu32, so a manifest declaring a largerepeatwith a non-trivialtextterminates probe-pin through allocation failure. That path produces noAbort, 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 valueReplace the
_arms with exhaustive matches overVerdict.
mounts_reachedandprovenanceboth use_ =>overOption<&Verdict>. A newVerdictvariant compiles silently and serializes asnullin both fields, so the digest stops pinning its payload. MatchSome(Verdict::Pass { .. }),Some(Verdict::Fail { .. })andNoneexplicitly, as theverdictfield 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 winThe
Instrumentvariant list is duplicated here and inused_instruments.
instrument_lineshard-codes[Instrument::Toolchain, Instrument::AstGrep]. A newInstrumentvariant compiles without a change here. Its committed// instrument <tool> = <version>line is then never parsed,changed_instrumentsnever sees a move for it, andwrite_refusalsilently 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 winThe 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 syntheticstreamhelper 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_jsonas a dev-dependency of the crate;verdict::observealready 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 valueRemove or use
astgrep_six.shin a test. The fixture is not referenced by the supplied tests.pure_logic::proj_missingusesastgrep_broken.shandastgrep_three.sh, whilepure_logic::instrument_skewandpure_logic::write_refusalgenerate the six-match case inskew_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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (30)
.cargo/config.tomlTiltfilecrates/probe-pin/Cargo.tomlcrates/probe-pin/src/block.rscrates/probe-pin/src/isolate.rscrates/probe-pin/src/lib.rscrates/probe-pin/src/main.rscrates/probe-pin/src/manifest.rscrates/probe-pin/src/mutate.rscrates/probe-pin/src/project.rscrates/probe-pin/src/target.rscrates/probe-pin/src/verdict.rscrates/probe-pin/tests/fixtures/astgrep_broken.shcrates/probe-pin/tests/fixtures/astgrep_six.shcrates/probe-pin/tests/fixtures/astgrep_three.shcrates/probe-pin/tests/fixtures/collide.tomlcrates/probe-pin/tests/fixtures/compiled.tomlcrates/probe-pin/tests/fixtures/control_expect_fail.tomlcrates/probe-pin/tests/fixtures/control_fails.tomlcrates/probe-pin/tests/fixtures/dogfood.tomlcrates/probe-pin/tests/fixtures/dogfood_block.mdcrates/probe-pin/tests/fixtures/p7.tomlcrates/probe-pin/tests/fixtures/p8.tomlcrates/probe-pin/tests/fixtures/prod.txtcrates/probe-pin/tests/fixtures/projection.tomlcrates/probe-pin/tests/fixtures/treewrite.tomlcrates/probe-pin/tests/fixtures/unsorted_ids.tomlcrates/probe-pin/tests/isolation_e2e.rscrates/probe-pin/tests/pure_logic.rsdocs/probe-pin.md
| 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(); |
There was a problem hiding this comment.
🩺 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.
| 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.
| #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] | ||
| #[serde(deny_unknown_fields)] | ||
| pub struct Output { | ||
| pub file: String, | ||
| pub marker: String, | ||
| } |
There was a problem hiding this comment.
🗄️ 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/testsRepository: 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.
| 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(), | ||
| ) | ||
| } |
There was a problem hiding this comment.
🩺 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/nullRepository: 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:
- 1: https://nexte.st/docs/configuration/test-groups/
- 2: https://github.com/nextest-rs/nextest/blob/main/site/src/docs/configuration/test-groups.md
- 3: Bug: serial tests start to run before other finish nextest-rs/nextest#2310
- 4: Feature request: Allow tests from different groups to not be interspersed nextest-rs/nextest#2054
- 5: https://nexte.st/docs/configuration/threads-required/
🏁 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)
PYRepository: 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.
| 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}"); |
There was a problem hiding this comment.
🎯 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.
| 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.
|
Generated for head Parse changes introduced by this PR✓ No card-parse changes detected. |
matthewevans
left a comment
There was a problem hiding this comment.
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.
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
|
🤖 AI text below 🤖 @matthewevans — addressed at Your review is pinned to
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:
So I took your second form as well. 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 " Regression test — Non-vacuity, measured rather than asserted: deleting the postcondition line makes the test fail at exit 101, and the panic names the injected second The mutated source was restored and re-verified byte-identical with Gate battery at this head, post-rebase onto |
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
|
🤖 AI text below 🤖 Follow-up at I claimed the
Two producers, no others. So the guard sits inside the only write's argument, not merely on its path. Four arms, each flipping a different assertion — dropping 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 Gate battery at this head: |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
crates/probe-pin/src/block.rs (1)
381-394: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winThe hand-written variant array defeats the compiler on a new
Instrument.Line 388 enumerates
[Instrument::Toolchain, Instrument::AstGrep]by hand. Add a third variant toInstrumentand this code still compiles. The new variant's// instrument <tool> = <version>line then resolves toNone,instrument_linesdrops it, andchanged_instrumentsnever reports it.The consequences are both in the classification this module owns:
checkreportsDriftCause::Codefor a run where only that instrument moved. The report on line 494 then tells the operator the block is stale.write_refusalnever fires for that instrument, sorun --writere-stamps across the change it cannot attribute — the exact case lines 460-462 say it must refuse.
used_instrumentson line 294 carries the same pair. GiveInstrumentone 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
📒 Files selected for processing (18)
Tiltfilecrates/probe-pin/src/block.rscrates/probe-pin/src/isolate.rscrates/probe-pin/src/lib.rscrates/probe-pin/src/main.rscrates/probe-pin/src/manifest.rscrates/probe-pin/src/target.rscrates/probe-pin/src/verdict.rscrates/probe-pin/tests/fixtures/collide.tomlcrates/probe-pin/tests/fixtures/compiled.tomlcrates/probe-pin/tests/fixtures/control_expect_fail.tomlcrates/probe-pin/tests/fixtures/control_fails.tomlcrates/probe-pin/tests/fixtures/projection.tomlcrates/probe-pin/tests/fixtures/treewrite.tomlcrates/probe-pin/tests/fixtures/unsorted_ids.tomlcrates/probe-pin/tests/isolation_e2e.rscrates/probe-pin/tests/pure_logic.rsdocs/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
| 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()); | ||
| } | ||
| } |
There was a problem hiding this comment.
🗄️ 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 checksMAX_PREPEND_BYTES, refuse a pad of 0 bytes, sorepeat = 0andtext = ""are rejected.crates/probe-pin/src/manifest.rs#L122-L126: add aMutation::Replacearm to the mutation loop that refuses an emptyfind(it matches at every position and rewrites the whole file) and refusesfind == 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.
…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
|
🤖 AI text below 🤖 @coderabbitai — all five verified against a run before anything was edited. Four fixed at Declined: identity mutations are already refused. The finding is right that
One guard covers the class regardless of mutation kind, including kinds not yet written. The proposed fix adds per-variant checks in Fixed:
On the missing-row finding — the suggested fix was wrong, and my test suite caught it. I applied The finding underneath it was still right, so A second existing test listed Each fix has a DROP arm flipping a different assertion: reverting Battery at this head: |
|
Tip For best results, initiate chat on the files or code changes.
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:
The regression and DROP-arm results support each change. ✏️ Learnings added
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
left a comment
There was a problem hiding this comment.
Approved after current-head review: the block-marker injection path is closed at manifest validation and final splice verification; complete CI is green.
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>
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>
…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>
🤖 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--checkcan re-verify for drift.The tool eats its own dogfood: its verification matrix is executed by running
probe-pinon itself, andcargo probe-pin checkon 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 manifestsdocs/probe-pin.md— usage, schema, isolation model, and the tool's stated non-guarantees.cargo/config.toml— one alias line (probe-pin)Tiltfile—probe-pin-checkresource so--checkhas an executor, andprobe-pin-e2eso the Tier-2 suite runs in the venue that has the capability CI lacksCargo.lock— purely additiveRoot
Cargo.tomlis not edited: the workspace globsmembers = ["crates/*"](Cargo.toml:5), so the crate joins automatically. Nobuild.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-impl→ class-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-pincontains no game logic; a CR annotation here would be false provenance.Verification
All measured after the rebase onto
upstream/maincc7668062, at head8f2836349:cargo fmt --all -- --check— exit 0cargo clippy --workspace --all-targets -- -D warnings— exit 0, zero warning linescargo test -p probe-pin— exit 0:pure_logic68 passed / 1 ignored,isolation_e2e0 passed / 17 ignored, 0 failedcargo test -p probe-pin --test isolation_e2e -- --ignored— exit 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.toml— exit 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:
pure_logic—ppfixture_ignored, a probe target whose body must never run. It is the fixture that drives the execution-floor verification below, not a skipped assertion.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-pinhas, and is green.Gate A
The base is passed explicitly. The script defaults to
git merge-base origin/main HEAD(:48), andoriginhere 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.shscopes toSCOPE='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: itsfindis 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:2andsrc/main.rs:1— the workspace's existing standalone tooling-crate pattern:version.workspace/license.workspace/edition 2021manifest shape, and a//!header naming the invocation that regenerates its output.probe-pinmirrors both.Tiltfile:199— the existinglocal_resourcelint pattern (list-formcmd, separateCARGO_TARGET_DIR, narrowdeps,TMP_IGNORE,auto_init,allow_parallel,labels).probe-pin-checkfollows it field for field, extendingignorebecauseTMP_IGNOREis a filename glob (**/*.tmp.*) that does not match atmp/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:
#[ignore]d pinned test now aborts instead of rendering a green row. Measured for both the#[ignore]and--benchroutes, with the benign arm still passing.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)matthewevansblocked 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) andanchor = "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
--versionoutput, and theprovenancepaths lifted from the target's own panic text. Neither can be refused in advance. Soblock::splicenow re-runslocateover its own spliced output and returns an abort rather than a block whose marker span the nextcheckcould 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_locateasserts both halves the review asked for:manifest::validate_block_textrefuses the injected marker, andsplicerefuses to return an unlocatable block. Two-sided — deleting the postcondition line makes it fail at exit 101 naming the injected secondEND; the mutated source was restored and re-verified withsha256sum -c.The census that premise rests on is now executable, not asserted. "Unconditional at the write surface" is only true while
spliceis the sole route to the pinned file's bytes. Measured: exactly two filesystem write producers exist insrc/— the scratch mutant, which is never a rendered block, and the pinned file, whose content argument is literally thesplice(..)?call. The guard is inside the only write's argument, not merely on its path.every_write_of_the_pinned_file_routes_through_splicepins that, and a secondfs::writeofoutput.filenow fails it rather than silently falsifying this paragraph.Four arms, each flipping a different assertion: dropping
splicefrom the write argument → exit 101 on the property; adding a third producer → exit 101 atfound 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-citedmanifest.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 andfound 3without.Second review round (resolved at head
815aaf279, census widened at8f2836349)Five findings; four real, one declined with evidence. Each was verified against a run before anything was edited.
firing_celldecided "this is the control" frommounts_reached == 0whilemutation_celldecided the same fact fromprobe.mutations.is_empty()— two discriminators for one concept, agreeing only becausevalidaterefuses 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 ingitandcheckexits 0.unmeasuredrow, matching whatdigestalready records for the same input. Skipping it showed N-1 rows for N declared probes whilecheckstayed CLEAN, because the committed copy omitted the same row.[target].filteris refused. Measured withfilter_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. Withexactit matches nothing and the execution floor blames the target for a typo in the manifest.probe-pin-e2egets its ownCARGO_TARGET_DIR, having shared one withprobe-pin-checkwhile both watchcrates/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
validateadmits identity mutations. It does; the refusal is not invalidate.find == replace,repeat = 0andtext = ""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, andfind = ""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 callsrenderwith&[]outcomes deliberately —renderis public and that input is legitimate, so the invariant belongs tomain's call order, not torender. The visibleunmeasuredrow 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::renameadded) and got the same answer — but the agreement was luck: neither appears in anysrc/module today, so a futurefs::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 anfs::copyintoblock.rsfails the census atfound 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,304tests/) 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.timeoutre-raises the child's signal, soExitStatus::code()isNoneon an abort, making the plan's own rc-134 contract unreachable without a128+signalmapping. Measured 134/139/101/124.${!m}indirection, noeval;"$@"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].argsmissed[target].filter, and a path guard written formutation.filemissed[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.
observetooktest_countfrom the suite-started record and discardedpassed/failed/ignoredfrom 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 reporttest_count 1. The floor is nowpassed + failed == 0. It caught--benchwithout--benchever 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 constrainsfilterby 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:
[[projection]].paths = ["-h"]madeast-grepprint 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.is_workspace_relativewas 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 patternisolate::scratch_diralready used, closing the probe-id,[output].fileand 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.
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 31646292055 —
unshare --map-root-user --mountfails withwrite 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. TheRust (fmt, clippy, test, coverage-gate)job is an aggregator reportingRUST_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 anddocs/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 beforeunshareis 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-e2eresource runs the suite with--ignoredin the local venue, which has the capability, so "ignored in CI" means "run elsewhere" and not "never run"; andpure_logic::tier2_suite_is_uniformly_ignored_with_the_measured_reasonpins 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 namingisolation_e2e.rs:130; adding an 18th test fails the size assertion instead.What CI still covers: all 68 executed
pure_logictests — schema, validation, verdict, digest, drift, and every surface refusal — but never a real mount. Stated plainly rather than left implied.probe-pin-checkcannot 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'scmddirectly (exit 0). Closing the Tilt-side gate is owned by me, post-merge.