Skip to content

Add the sabotage harness, sweeping a copy of the tree - #64

Open
MikeGrier wants to merge 10 commits into
mainfrom
mikegrier/sabotage-harness
Open

Add the sabotage harness, sweeping a copy of the tree#64
MikeGrier wants to merge 10 commits into
mainfrom
mikegrier/sabotage-harness

Conversation

@MikeGrier

@MikeGrier MikeGrier commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Peels the sabotage harness out of the PR #56 branch, because main needs it now
rather than whenever that branch lands.

Why this is not just tidying

PRs #59 and #63 landed crates/windows-waitable-queues/sabotage.json and
crates/windows-placement-probe/sabotage.json. Both open with:

Run with tools/run-sabotage.ps1; see tools/README-sabotage.md for the format
and for why the results are read the way they are.

Neither file was in main. So main has been shipping two manifests pointing
at a harness that is not in the tree -- one of them for a published crate.

What the harness does

It takes a manifest of deliberate defects and, for each: patches the source,
runs the suite, and records whether the suite noticed. That measures the claim a
green run does not make -- that the tests would fail if the code were wrong.

It exits 0 only when every entry behaves as the manifest declared, and reports
MANIFEST STALE when a pattern stops matching exactly one site, so a sabotage
that silently stopped applying cannot read as a pass. It is deliberately not
a CI gate.

It never touches your working tree

The sweep runs against a copy, refreshed from the working tree at the start
of each run, with its own cargo target directory -- the same approach
cargo mutants takes.

This is the PR's main design decision, and it was made late, after review had
worked through the in-place version. It is a premise rather than a precaution:
patching the developer's own files means every sabotage needs a backup, a
restore, a check that the restore worked, a dirty-tree guard, a containment
check, and a recovery path for when any of that is interrupted -- and each is a
chance to damage work that was never in a commit.

Of the eighteen defects found across seven review rounds here, eight were in
that machinery, and every data-loss risk was one of them.
The last was the
sharpest: a byte-exact restore via File.Copy carried the original mtime, so
cargo judged the crate up to date against an artifact built from patched
source and left the sabotaged binary in the developer's build cache.

Working against a copy deletes the backup files, the restore, the restore
verification, the leftover-backup guard, the dirty-tree guard, the git
exit-code check it needed, the -AllowDirty switch, and exit code 3. Two
behaviour improvements fall out: a dirty tree is now swept exactly as it stands
(uncommitted edits are usually the code whose guards you are asking about), and
a bug in this tool now costs a scratch directory rather than your work.

Verification

  • Real tree untouched: all 570 tracked files fingerprinted by content hash
    and mtime before and after a sweep; the sets match exactly.
  • Copy is faithful: 570 files compared against the real tree, 0 differ.
  • placement-probe (9 entries): 9/9 as declared, exit 0, on PowerShell 7 and
    Windows PowerShell 5.1. Parses clean on both.
  • waitable-queues (39 entries): 34 as declared. The other 5 are
    MANIFEST STALE against main's own source -- see below.
  • Uncommitted work is swept: an uncommitted edit that breaks a test turns
    the baseline red, proving the copy reflects the working tree, not HEAD.
  • Cost: cold 69s, warm 49s for the 9-entry manifest; scratch holds 7 MB of
    tree and 379 MB of target.
  • tools/check-encoding.ps1, which CI runs, passes: 570 files clean.

A pre-existing defect this found in main

Five of the 39 entries in main's windows-waitable-queues/sabotage.json have
find patterns that occur zero times in main's own
windows-waitable-queues source: the final drain returns nothing and four
reserving_mpsc: entries.

This is not a regression from this PR -- the copy is proven byte-identical to
the real tree, and the patterns were measured directly against the real files.
It means five guards on a published crate are currently unverified, and a
green sweep would never have said so. That the harness reports it as
MANIFEST STALE rather than counting those entries as caught is the whole
reason it distinguishes the two.

Repairing them needs judgement about what reserving_mpsc should now be
sabotaged at, which is a different piece of work from adding the tool, so it is
not fixed here.

Note for whoever lands PR #56

tools/run-sabotage.ps1 and tools/README-sabotage.md also exist on
mikegrier/deferred-namespace-ops. Once this merges, that branch's next merge
from main hits an add/add conflict on both -- the same thing that happened
with the placement probe. Deleting them there and taking main's copies is the
clean resolution; main's are substantially further along.

Mike Grier and others added 2 commits September 5, 2026 14:24
…eady cite

Peeled from the PR #56 branch, where this was written, because main needs it
now rather than when that branch lands.

PR #59 and PR #63 landed crates/windows-waitable-queues/sabotage.json and
crates/windows-placement-probe/sabotage.json. Both manifests open by saying
"Run with tools/run-sabotage.ps1; see tools/README-sabotage.md for the format
and for why the results are read the way they are." Neither file existed here:
main's tools/ held only check-baseline.ps1, check-borrow-surface.ps1 and
check-encoding.ps1. So main has been shipping two manifests pointing at a
harness that is not in the tree, for a crate that is published.

What the harness is: it takes a manifest of deliberate defects and, for each,
patches the source, runs the suite, restores the source, and records whether
the suite noticed. That measures the claim a green run does not make -- that
the tests would fail if the code were wrong. It exits 0 only when every entry
behaves as the manifest declared, and reports MANIFEST STALE when a pattern no
longer matches, so a sabotage that silently stopped applying cannot read as a
pass.

Deliberately not wired into CI. Every entry forces a rebuild and any caught as
a hang costs the full test timeout, so the waitable-queues manifest takes about
three minutes; the README says to run it when a guard is written or changed.

Scope is the two files the manifests name, and nothing else. run-sabotage.ps1
takes a manifest path and references no other script, so the mutation-sweep
tooling it sits beside on the source branch is not needed here and is not
included.

Verified on this branch, which is main plus these two files:
  - Both manifests enumerate: 39 entries for windows-waitable-queues and 9 for
    windows-placement-probe, every pattern resolving against the source already
    in main, so neither is stale.
  - Both CONTROL entries declare "survives" -- the manifest checking itself.
  - tools/check-encoding.ps1, which CI runs, passes: 570 files clean.

CRLF on disk is required rather than incidental: .gitattributes sets
*.ps1 text eol=crlf, and git stores LF, matching the three scripts already
here (i/lf w/crlf).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
All three were present on the source branch and are fixed here rather than
carried into main. Each is verified by making the harness demonstrate the old
behaviour and then the new one.

1. -AllowDirty made the tool's own recovery advice destructive.

The pre-patch contents lived only in the in-memory $original, so the restore
advice was "recover it with 'git checkout -- <file>'". That is lossless only
when the file was clean -- which is exactly the precondition -AllowDirty
waives. Following it on a file carrying uncommitted work would revert to HEAD
and destroy that work, and an interruption (Ctrl+C, crash, Stop-Process) took
$original with it, leaving checkout as the only recourse.

Pre-patch contents are now written to <output>/restore/ before the file is
touched, and removed once the restore is verified, so a file left there means
an interrupted run. The failure message names that backup and says plainly not
to reach for git checkout.

2. A patch that only broke a doctest was reported as caught.

The build phase is `cargo test --no-run`, which does not build doctests, and
cargo rejects `--doc --no-run` outright ("can't skip running doc tests" --
verified on 1.98.0), so they cannot be pre-paid. A patch valid in the crate but
not in a `///` example therefore passed the build and failed the run with a
compile error, reported as `caught (suite failed, exit 101)`. That is the
weaker claim wearing the stronger one's label, and it is the specific confusion
the build/test split exists to prevent.

Such a run is now reclassified as MANIFEST DOES NOT COMPILE (a doctest would
not build), detected from rustdoc's fixed "Couldn't compile the test." marker.
Reading a transcript is a deliberate exception to judging by exit code, taken
because the exit code is 101 either way and cannot distinguish them; the marker
was verified on this toolchain to land on stdout.

Latent rather than live for the two shipped manifests -- all 48 entries are
behavioural and none change an item signature -- but nothing prevented the next
one from doing so, and it failed in the direction that looks safe.

3. A failed baseline pointed at a transcript that was stale or absent.

The abort named baseline.txt unconditionally. A baseline that fails in the
BUILD phase never reaches the test phase, so that file is never written by that
run -- and because transcripts were never cleaned between runs, the path could
still hold a green transcript from an earlier sweep, contradicting the message
pointing at it. Confirmed: a 22 KB passing baseline.txt survived a subsequent
build-phase failure.

Messages now name the transcript for the phase that actually failed
(.build.err, since cargo writes diagnostics to stderr), and stale transcripts
are cleared at startup so a named path is always from the current run.

Verified:
  - Regression: the placement-probe sweep still reports 9/9 as declared, exit
    0, with no leftover backups and a clean tree.
  - Defect 2: a probe manifest renaming a function a doctest calls previously
    reported "caught (suite failed, exit 101)" and now reports MANIFEST DOES
    NOT COMPILE (a doctest would not build), exiting 1 rather than agreeing.
  - Defect 3: an induced build-phase baseline failure now names
    baseline.txt.build.err, which holds the real rustc error, and the
    previously-green baseline.txt is gone rather than stale.
  - tools/check-encoding.ps1 passes (570 files clean); the script parses clean;
    check-commit-scope.ps1 -Staged -Type fix reports no cross-crate span.

README-sabotage.md is updated in step so the documented behaviour and the
implemented behaviour do not diverge.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings September 5, 2026 18:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The harness has a couple of correctness/safety edge cases (cargo --no-run placement and restore-backup handling) that can yield misleading results or overwrite recovery data.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds the missing sabotage sweep harness and documentation that existing shipped sabotage.json manifests already reference, so main can actually run those sweeps as intended.

Changes:

  • Add tools/run-sabotage.ps1 to apply one sabotage patch at a time, run bounded build+test phases, and record outcomes.
  • Add tools/README-sabotage.md documenting the sweep workflow, result interpretation, and manifest format.
File summaries
File Description
tools/run-sabotage.ps1 Implements the sabotage harness: patch/apply/restore loop with bounded cargo build+test phases and per-sabotage transcripts/backups.
tools/README-sabotage.md Documents how to run sweeps and author manifests, matching the harness behavior and outcomes.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tools/run-sabotage.ps1 Outdated
Comment on lines +304 to +306
$backupDirectory = Join-Path $OutputDirectory 'restore'
New-Item -ItemType Directory -Force -Path $backupDirectory | Out-Null

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 60e548d, and this one was a defect in the previous commit rather than in the peeled original -- that commit introduced the backup and asserted "a file left in that directory means a run was interrupted before it could restore", then did nothing to make the assertion hold. A documented claim the code does not enforce is exactly the failure mode, so thank you.

A sweep now refuses to start while any backup is present, names each file, and says what to do with it. Verified by planting one and confirming both halves:

Pre-patch backups from an earlier run are still present:
  ...\.scratch\sabotage\restore\pretend-interrupted.report.rs.bak
That run was interrupted before it could restore its target, so each of
these may be the only copy of the file it names -- under -AllowDirty,
including uncommitted work that is in no commit.
...
This sweep will not start while they are here, because it would overwrite them.
exit: 2

The planted file was still on disk at 21 bytes afterwards. Preserving it is the point; merely detecting it would not have been.

Comment thread tools/run-sabotage.ps1 Outdated
Comment on lines +415 to +427
$transcript = Join-Path $OutputDirectory ((($sabotage.name -replace '[^A-Za-z0-9]+', '-')) + '.txt')

# The pre-patch contents, on disk and not only in $original.
#
# $original is a variable, so it dies with the process: an interruption --
# Ctrl+C, a crash, Stop-Process -- leaves the file patched with no in-memory
# copy to put back. `git checkout` recovers that only when the file was
# clean to begin with, which is precisely what -AllowDirty waives. Writing
# the backup first is what lets the restore advice below be non-destructive
# in both modes rather than only in the default one.
$backup = Join-Path $backupDirectory `
((($sabotage.name -replace '[^A-Za-z0-9]+', '-')) + '.' + (Split-Path -Leaf $target) + '.bak')
[System.IO.File]::WriteAllText($backup, $original, $utf8NoBom)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 60e548d. Latent rather than live -- I checked both shipped manifests, and all 39 waitable-queues names and all 9 placement-probe names reduce to distinct stems -- but worth closing, and I agree the backup half is the serious one: a shared transcript loses one entry's evidence, while a shared backup path puts two different files' recovery copies at one location.

I took the "compute the stem once" half of your suggestion but not the "fail fast if the path already exists" half, deliberately. Checking for an existing path reports a stale-file symptom, and the actual problem is a manifest containing two entries that cannot be told apart. So the stems are computed and cross-checked up front, and a collision names both offenders:

Two sabotages in this manifest reduce to the same file name stem,
so they would share a transcript and a restore backup:
  probe: colliding name
  probe - colliding name
Both become 'probe-colliding-name'. Rename one so the two differ by more than
punctuation.
exit: 2

The loop then consumes that precomputed stem rather than re-deriving it, so the sanitiser is spelled in one place and the guard cannot end up checking a name the writes do not use. Leftover backups from an interrupted run are covered separately by the guard in the other thread.

Comment thread tools/run-sabotage.ps1 Outdated
Comment on lines +216 to +217
$build = Invoke-Bounded -CargoArgs ($CargoArgs + '--no-run') -WorkingDirectory $WorkingDirectory `
-TranscriptPath "$TranscriptPath.build" -Seconds $BuildSeconds

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 60e548d. Real, and latent rather than live -- verified neither shipped manifest's testArgs contains a --, so nothing currently lands past a separator.

Agreed on the consequence, and it is worse than a wrong-argument error: with --no-run going to libtest, the build phase would actually run the tests, so the phase split that keeps ''the compiler rejected this'' apart from ''the tests caught this'' would have been measuring neither.

--no-run is now inserted before any separator via a small Add-CargoFlag helper. Exercised directly on four vectors:

in out
test -p pkg --locked test -p pkg --locked --no-run
test -p pkg -- --nocapture test -p pkg --no-run -- --nocapture
test -- --test-threads=1 test --no-run -- --test-threads=1
-- --nocapture --no-run -- --nocapture

The last case is why the helper uses Select-Object rather than a range: a -- at index 0 makes the obvious spelling \[0..(\-1)] into [0..-1], and a negative index counts from the end in PowerShell, so it would have silently reversed the vector instead of yielding nothing.

…indings

All three are latent rather than live against the two shipped manifests --
verified: 39/39 and 9/9 sabotage names reduce to distinct stems, and neither
manifest's testArgs contains a `--` separator. They are fixed anyway, because
two of them can destroy the recovery copy the previous commit just introduced,
and the third silently measures the wrong thing.

1. A sweep would overwrite an interrupted run's only recovery copy.

The previous commit wrote pre-patch contents to <output>/restore/ and claimed a
file left there means an interrupted run. Nothing acted on that claim: the next
sweep would overwrite the backup it tells the reader to go and find, and under
-AllowDirty that copy may hold uncommitted work that is in no commit. A sweep
now refuses to start while any backup is present, names each one, and says what
to do with it -- which is what makes the claim load bearing rather than
decorative.

2. Two sabotage names could collide into one transcript and one backup.

Both file names come from the sabotage's name with runs of non-alphanumerics
collapsed to a dash, so "a: b" and "a - b" both become "a-b". Sharing a
transcript loses one entry's evidence; sharing a backup path is worse, putting
two different files' recovery copies at one location. The stem is now computed
once, up front, and a collision is rejected by naming the two entries that
cannot be told apart -- rather than being defended against at each write, which
would report a stale-file symptom instead of the manifest's actual problem. The
loop consumes that precomputed stem, so the sanitiser is spelled once and the
check cannot end up guarding a name the writes do not use.

3. `--no-run` was appended after any `--`, handing it to the test binary.

Everything after `--` belongs to libtest, not cargo. A manifest whose testArgs
ended in test-binary arguments would have had `--no-run` appended past the
separator, so the build phase would have run the tests instead of building
them, then failed for a reason unrelated to the sabotage -- and the phase split
that keeps "the compiler rejected this" apart from "the tests caught this"
would have been measuring neither. The flag is now inserted before the
separator.

Verified:
  - Flag insertion, exercised directly on four vectors: no separator appends;
    `test -p pkg -- --nocapture` yields `test -p pkg --no-run -- --nocapture`;
    and a `--` at index 0 is handled, which is the case that matters because
    the obvious range spelling ($args[0..-1]) counts backwards in PowerShell
    and would silently reverse the vector.
  - Collision guard: a probe manifest with "probe: colliding name" and
    "probe - colliding name" is rejected, naming both and the shared stem.
  - Leftover guard: with a planted backup, the sweep refuses and the planted
    file is still on disk afterwards -- the point being that it is preserved,
    not merely detected.
  - Regression: the placement-probe sweep still reports 9/9 as declared, exit
    0, no leftover backups, clean tree.
  - Script parses clean; check-encoding.ps1 passes (570 files);
    check-commit-scope.ps1 -Staged -Type fix reports no cross-crate span.

README-sabotage.md is updated in step, so the documented behaviour and the
implemented behaviour do not diverge.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 5, 2026 18:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The harness has two concrete correctness hazards (git dirtiness check pathspec and a reserved transcript-name collision) that should be fixed before landing.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

tools/run-sabotage.ps1:405

  • Transcript file names are derived from a sanitized sabotage name stem, but baseline.txt is also written into the same directory. A sabotage named (or sanitizing to) baseline would collide with the baseline transcript and corrupt the run's evidence.

Consider rejecting reserved stems like baseline during the preflight stem-collision check.

foreach ($sabotage in $selected) {
    $stem = $sabotage.name -replace '[^A-Za-z0-9]+', '-'
    if ($stemOwners.ContainsKey($stem)) {
        Exit-WithMessage (@(
                "Two sabotages in this manifest reduce to the same file name stem,"
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/run-sabotage.ps1 Outdated
Comment on lines +420 to +426
$target = Join-Path $sourceRoot $sabotage.file
if (-not (Test-Path -LiteralPath $target)) {
Exit-WithMessage "Sabotage '$($sabotage.name)' names a file that does not exist: $target" 2
}
if (-not $AllowDirty) {
$status = git -C $repoRoot status --porcelain -- $target
if ($status) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 8a2797b, though not for the stated reason -- and the difference matters, so here is the evidence.

The reported mechanism does not reproduce. Git resolves an absolute pathspec against the repository root; it does not require a repo-relative one. With crates/windows-waitable-queues/src/spsc.rs modified, the exact pathspec this script builds matches, from the repo root and from an unrelated working directory alike:

target pathspec : Q:\github\windows-threadpool-sys\crates\windows-waitable-queues\src\spsc.rs
=== does git match the absolute pathspec? ===
MATCHED -> ' M crates/windows-waitable-queues/src/spsc.rs'
=== same query from an unrelated CWD ===
MATCHED -> ' M crates/windows-waitable-queues/src/spsc.rs'

(git 2.55.0.windows.4. Join-Path also normalises the manifest's forward slashes, so the mixed-separator form never actually reaches git.) There was already evidence for this: the guard fired correctly during the previous round's testing, which it could not have done if absolute paths failed to match.

But steelmanning it found a real defect one layer down, which is now fixed. The script read git's stdout and never checked its exit code. A query that genuinely fails prints to stderr and leaves $status empty -- indistinguishable from "the file is clean". The reachable case is a manifest whose root resolves outside the repository, since root may point anywhere:

Could not determine whether this sabotage target is clean in git:
  Q:\github\outside-repo-probe.txt
git exited 128 and said:
  fatal: ... is outside repository at 'Q:/github/windows-threadpool-sys'
Refusing to proceed: a failed check is not a clean result, and
treating it as one is how a sweep overwrites uncommitted work.

Before this commit that same case passed the guard silently and went on to patch a file whose cleanliness was never established. So: right instinct, wrong mechanism, real bug. Thanks.

Second PR #64 review round. One finding taken as reported; the other's stated
mechanism did not reproduce, but steelmanning it found a real defect nearby,
which is what this fixes.

1. A sabotage named "baseline" would overwrite the baseline transcript.

Transcripts are named from the sabotage's stem, and baseline.txt plus its phase
variants are written into the same directory before the sweep starts. A stem of
`baseline` would therefore land on top of the record that the suite was green
before any patching -- which is the premise every result in the run depends on.
Rejected up front alongside the existing collision check, case-insensitively,
because the sanitiser preserves case while the filesystem does not.

2. A git dirtiness query that FAILED was indistinguishable from "clean".

The review reported this as absolute pathspecs failing to match. That does not
reproduce: git resolves an absolute pathspec against the repository root, and it
matches from any working directory. Verified directly -- with
crates/windows-waitable-queues/src/spsc.rs modified, the exact pathspec this
script builds returns " M crates/windows-waitable-queues/src/spsc.rs" both from
the repo root and from an unrelated directory, on git 2.55.0.windows.4. The
guard firing correctly in the previous round's testing was already evidence of
this.

The real defect is one layer down. The script took git's stdout and never
looked at its exit code, so any failed query -- a manifest `root` resolving
outside the repository is the reachable case, since `root` may point anywhere
-- sent its diagnostic to stderr, left $status empty, and read as "this file is
clean". The sweep would then patch a file whose cleanliness was never
established. A guard that cannot distinguish "clean" from "I could not check"
is not a guard, so the exit code is now inspected and a failed query aborts.

Verified:
  - Reserved stem: a manifest entry named "baseline" is rejected, naming the
    stem and the file it would collide with. (Note "Baseline!" sanitises to
    "Baseline-" and is correctly NOT rejected -- only an exact collision is.)
  - Failed query: a manifest whose root resolves outside the repository now
    aborts with git's own message and exit 128 quoted back. Before this change
    that same case passed the guard silently.
  - Regression: the placement-probe sweep still reports 9/9 as declared, exit
    0, no leftover backups, clean tree.
  - Script parses clean; check-encoding.ps1 passes (570 files);
    check-commit-scope.ps1 -Staged -Type fix reports no cross-crate span.

README-sabotage.md is updated in step, so the documented behaviour and the
implemented behaviour do not diverge.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 5, 2026 20:21
@MikeGrier

Copy link
Copy Markdown
Owner Author

Also addressed the suppressed comment from this round (tools/run-sabotage.ps1:405, the reserved baseline stem) in 8a2797b -- it was a real collision and worth closing.

Transcripts are named from the sabotage's stem, and baseline.txt plus its .build / .build.err / .err variants are written into the same directory before the sweep starts. A stem of baseline would land on top of the record that the suite was green before any patching -- which is the premise every result in the run depends on, so losing it is worse than losing one entry's transcript.

Rejected up front alongside the existing collision check, case-insensitively, since the sanitiser preserves case while the filesystem does not:

This sabotage's name reduces to a file name stem this directory
already uses for its own transcripts:
  baseline
It becomes 'baseline', which would collide with 'baseline.txt'. Rename it.
exit: 2

One note from testing it: Baseline! sanitises to Baseline-, not baseline, and is correctly not rejected. Only an exact collision is refused, which is the intent -- the guard is about the one name that is actually taken, not about names that merely resemble it.

@MikeGrier
MikeGrier marked this pull request as ready for review September 5, 2026 20:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

tools/run-sabotage.ps1 includes PowerShell 7-only syntax (? :) that will fail to parse in Windows PowerShell 5.1, so the tool may not run reliably in all expected environments.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/run-sabotage.ps1
Comment on lines +190 to +192
if ($process.WaitForExit($Seconds * 1000)) {
return [pscustomobject]@{ Outcome = ($process.ExitCode -eq 0 ? 'passed' : 'failed'); Code = $process.ExitCode }
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in f987320. Confirmed, and worse than reported -- then acting on it uncovered a second defect behind it that was worse still.

The reported defect, confirmed. It is not just that line: the ternary makes the whole script unparseable under 5.1, so it fails before line 1 executes. Measured with 5.1's own parser (5.1.26100.9168):

PS 5.1 PARSE ERRORS: 9
  line 191: Unexpected token '?' in expression or statement.
  line 191: Missing closing ')' in expression.
  line 191: The hash literal was incomplete.
  ... 6 more cascading

You were right about the sibling scripts too -- check-baseline.ps1, check-borrow-surface.ps1 and check-encoding.ps1 contain zero PowerShell 7 operators, so this one was the odd man out. Rewritten as an if/else.

But fixing only that would have been worse than leaving it. With the script parseable, I ran the full sweep under 5.1 and the baseline failed -- while its own transcript said Finished `test` profile in 2.36s. The build had succeeded and been reported as failed.

Cause: a Start-Process -PassThru Process object on 5.1 does not cache the native handle, so once the process exits .ExitCode reads back $null. Measured directly:

--- WITHOUT touching .Handle ---   ExitCode -> []   isNull=True
--- WITH .Handle touched first --- ExitCode -> [0]  isNull=False

$null -eq 0 is false, so every phase would classify as failed -- meaning the baseline can never pass and, had it passed, every sabotage would report caught. That is a clean bill of health that proves nothing, which is the one result this harness exists to make impossible. The ternary at least failed loudly; repairing it alone would have converted a parse error into a silent wrong answer.

Reading and discarding $process.Handle before the wait keeps the handle alive. It is a no-op on PowerShell 7.

Verified on both shells: parses clean under 5.1 and 7; full sweep under 5.1 now reports 9/9 as declared, exit 0, no leftover backups, clean tree (same invocation aborted at the baseline before this commit); full sweep under 7 unchanged at 9/9.

The README now states 5.1/7 support and why the difference bites here, so the constraint reads as deliberate to the next person editing this file. Good catch -- this one paid off well beyond the line it pointed at.

Third PR #64 review round. The reported defect was real, and acting on it
uncovered a second one behind it that was strictly worse.

1. A PowerShell 7 ternary made the script unparseable on 5.1.

`Invoke-Bounded` computed its outcome with `? :`, which is a PARSE error under
Windows PowerShell 5.1 -- so the whole script failed before its first line ran,
on the shell `powershell.exe` still starts by default. Measured: 9 cascading
parse errors, all from that one expression. The three sibling scripts in this
directory use no PowerShell 7 syntax, so this one was also the odd man out.
Rewritten as an if/else.

2. Behind it: Start-Process exit codes are $null on 5.1, so every phase
   "failed".

Fixing the parse error alone would have been worse than leaving it. A Process
object from `Start-Process -PassThru` on 5.1 does not cache the native handle;
once the process exits the handle is released and `.ExitCode` reads back $null
-- for a process that exited 0 exactly as for one that failed. `$null -eq 0` is
false, so every phase classified as 'failed'.

That is not a loud failure. The baseline can never pass, and had it passed,
every sabotage would have reported `caught` -- a clean bill of health that
proves nothing, which is the single result this harness exists to make
impossible. The ternary was failing loudly; repairing only it would have
converted that into a silent wrong answer.

Reading and discarding `$process.Handle` before the wait keeps the handle alive
so the exit code survives. A no-op on PowerShell 7, which caches it itself.

Measured rather than assumed: under 5.1, `cmd /c exit 0` reports ExitCode $null
without that line and 0 with it. Found by actually running the suite under 5.1
rather than by reading the diff -- the first 5.1 run reported the baseline as
build-failed while its own transcript said "Finished `test` profile in 2.36s",
which is what pointed at the exit code rather than at the build.

Verified:
  - Parses clean under both 5.1 and 7.
  - FULL sweep under 5.1: 9/9 behaved as declared, exit 0, no leftover backups,
    clean tree. Before this commit the same invocation aborted at the baseline.
  - FULL sweep under 7: unchanged, 9/9, exit 0.
  - check-encoding.ps1 passes (570 files); check-commit-scope.ps1 -Staged -Type
    fix reports no cross-crate span.

README-sabotage.md now states 5.1/7 support and why the difference matters
here, so the next change to this file knows the constraint is deliberate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 5, 2026 20:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new harness currently has a couple of operational footguns (over-broad output-dir cleanup and allowing out-of-repo mutation under -AllowDirty) that should be tightened before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

tools/run-sabotage.ps1:348

  • OutputDirectory cleanup deletes all files in the directory (Get-ChildItem ... -File | Remove-Item). Since -OutputDirectory is user-supplied, this can unintentionally remove unrelated files that aren’t transcripts. The docs only promise stale transcripts are cleared, so it’s safer to restrict deletion to the transcript naming convention this script writes (".txt") instead of every file in the directory.
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/run-sabotage.ps1 Outdated
Comment on lines +462 to +467
foreach ($sabotage in $selected) {
$target = Join-Path $sourceRoot $sabotage.file
if (-not (Test-Path -LiteralPath $target)) {
Exit-WithMessage "Sabotage '$($sabotage.name)' names a file that does not exist: $target" 2
}
if (-not $AllowDirty) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in bc689b8. Correct, and this one is a defect this PR introduced rather than inherited -- my round-2 exit-code check only guarded the -not $AllowDirty branch, so containment was never checked at all under the switch.

Agreed on the framing: -AllowDirty is documented as waiving the cleanliness requirement, and it was in effect waiving the boundary too. Those are different things, and the boundary is the one with no git checkout behind it. The check now runs for every target on every run, before the dirtiness query so an out-of-repo path is reported as what it is rather than as a git pathspec failure:

=== containment, default (no -AllowDirty) ===
Sabotage 'probe outside repo' names a file outside this repository:
  Q:\github\outside-repo-probe.txt
...refused whether or not -AllowDirty was passed -- that switch
waives the cleanliness check, not the boundary.
exit: 2

=== containment, WITH -AllowDirty (the reported gap) ===
[identical output]
exit: 2

=== was the out-of-repo file left untouched? ===
nonexistent

Before this commit the second case patched that file. The comparison is against a canonicalised root with a trailing separator, so a sibling directory whose name merely starts with the root's (...\repo-notes vs ...\repo) cannot pass as inside it.

…ystanders

Fourth PR #64 review round. Both findings are defects this PR introduced rather
than ones it inherited, in the guards added for the first and second rounds.

1. -AllowDirty widened what could be modified, not just what was checked.

The switch is documented as waiving the CLEANLINESS requirement. It was also,
in effect, waiving containment: with it passed, the git guard was skipped
entirely and the tool would patch whatever path a manifest's `root` resolved to,
including one outside the repository. Those are two different things, and the
second is the one with no `git checkout` behind it.

Containment is now checked for every target on every run, before the dirtiness
query so an out-of-repo path is reported as what it is rather than as a git
pathspec failure. The comparison is against a canonicalised repository root
with a trailing separator, so a sibling directory whose name merely starts with
the root's cannot pass as being inside it.

2. Transcript cleanup deleted every file in a caller-supplied directory.

The stale-transcript clearing added in the second round was
`Get-ChildItem $OutputDirectory -File | Remove-Item`. $OutputDirectory is a
parameter, so pointing it anywhere holding other files meant the sweep took
them -- a destructive surprise well outside anything the tool documents.

It now removes precisely the paths this invocation can write: `baseline` plus
each selected sabotage's stem, times the four transcript suffixes. That meets
the original goal exactly -- a transcript named in an error message is always
from this run or absent -- with no collateral. It is also stricter than
filtering on the `*.txt*` naming convention, which would still have taken an
unrelated `notes.txt`.

Moved after the -List exit and after target validation, so neither listing a
manifest nor being rejected by one deletes anything.

Verified:
  - Containment: a manifest rooted outside the repository is refused
    identically with and without -AllowDirty, and the out-of-repo file is
    afterwards byte-for-byte untouched. Before this commit, -AllowDirty patched
    it.
  - Cleanup: with `unrelated-notes.txt`, `important.dat` and a stale
    `baseline.txt` planted in the output directory, `-List` deletes nothing,
    and a full sweep replaces only `baseline.txt` while both bystanders survive
    with their contents intact -- including the one that matches `*.txt*`.
  - Full sweep on PowerShell 7 and on Windows PowerShell 5.1: 9/9 behaved as
    declared, exit 0, no leftover backups, clean tree. Parses clean on both.
  - check-encoding.ps1 passes (570 files); check-commit-scope.ps1 -Staged -Type
    fix reports no cross-crate span.

README-sabotage.md is updated in step: the boundary is stated as not waivable
by -AllowDirty, and the cleanup is described as covering only this run's own
transcripts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 5, 2026 21:00
@MikeGrier

Copy link
Copy Markdown
Owner Author

Also fixed the suppressed comment from this round (tools/run-sabotage.ps1:348, the over-broad -OutputDirectory cleanup) in bc689b8. Also a defect this PR introduced -- that blanket delete was my round-2 stale-transcript fix, and $OutputDirectory being a parameter makes it a genuinely destructive surprise.

I went further than the suggested *.txt* filter, because that filter is not actually safe: the tool's own transcripts and an unrelated notes.txt are indistinguishable by pattern. Instead it now deletes precisely the paths this invocation can write -- baseline plus each selected sabotage's stem, times the four transcript suffixes. That meets the original goal exactly (a transcript named in an error message is always from this run or absent) with no collateral whatsoever.

Verified by planting bystanders, including one that *.txt* would have taken:

=== before ===
baseline.txt           44   <- stale "STALE GREEN TRANSCRIPT from an earlier run"
important.dat          16
unrelated-notes.txt    26   <- matches *.txt*

=== after -List ===
[all three unchanged]

=== after a full sweep ===
baseline.txt        22314   <- replaced, stale text gone
important.dat          16   <- 'unrelated data'
unrelated-notes.txt    26   <- 'a caller kept notes here'

It also moved after the -List exit and after target validation, so neither listing a manifest nor being rejected by one deletes anything -- -List is now read-only, which it should always have been.

Both fixes re-verified with a full sweep on PowerShell 7 and Windows PowerShell 5.1: 9/9 as declared, exit 0, no leftover backups, clean tree.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The new harness should handle malformed/invalid manifests via controlled Exit-WithMessage paths (parse + required-field validation) to match its stated behavior and avoid hard-to-diagnose failures.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

tools/run-sabotage.ps1:338

  • Manifest JSON parse errors currently surface as a terminating PowerShell error (from ConvertFrom-Json) rather than a controlled Exit-WithMessage exit code. That contradicts the script's own intent to report a bad manifest without aborting the caller's session/script, and it also makes the failure mode less actionable (no consistent exit code 2).

This issue also appears on line 376 of the same file.

tools/run-sabotage.ps1:378

  • The script assumes package and sabotages are present in the manifest, but doesn't validate them. If package is missing (and testArgs isn't provided), the baseline will run cargo test -p --locked and fail later with a cargo error; if sabotages is missing/empty, the script reports a misleading "No sabotage ... matches name filter" even when the manifest is malformed. Validating these fields up front makes failures deterministic and actionable.
$package = $spec.package
$testArgs = @('test', '-p', $package, '--locked')
if ($spec.PSObject.Properties.Name -contains 'testArgs' -and $spec.testArgs) {
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Fifth PR #64 review round. Both findings taken, and the class turned out to be
six failure modes rather than the two reported, so all six are fixed together.

The script sets $ErrorActionPreference = 'Stop', so an unguarded Resolve-Path
or ConvertFrom-Json failure raised a terminating error that propagated out and
took the caller's session with it. That is exactly what Exit-WithMessage exists
to prevent -- its own comment says so, and has since the original file -- so
this was a contract the code stated and did not keep. It also surfaced as a
PowerShell stack frame naming a line of this script when the thing that was
wrong was the manifest, which is the wrong direction to send a reader.

Measured before, across both invocation paths:

  missing manifest file  -List=1  sweep=1   raw Resolve-Path error
  malformed JSON         -List=1  sweep=1   raw ConvertFrom-Json error
  missing package        -List=1  sweep=1   "The property 'package' cannot be found"
  missing sabotages      -List=1  sweep=1   "The property 'sabotages' cannot be found"
  empty sabotages        -List=0  sweep=2   -List reported SUCCESS on a manifest
                                            with nothing in it
  bad root               -List=1  sweep=1   raw Resolve-Path error

And after: every one is exit 2 on both paths, with a message naming the file
and the field. 2 is the code this script already used for "the manifest or the
invocation is wrong", as distinct from 1 for "a sabotage did not behave as
declared", so the three outcomes a caller cares about are now actually
distinguishable.

An empty `sabotages` is reported as an empty manifest rather than as "no
sabotage matches name filter '*'". The old wording sent the reader to look at a
filter they had not set.

Per-sabotage fields are validated too, for the same reason: every field in the
README's format table is required, and under Set-StrictMode a missing one
surfaced as "The property 'x' cannot be found" against a line of this script.
`expect` is additionally checked to be `caught` or `survives`, because an
unrecognised value is not inert -- it is compared for equality when scoring, so
it could never match and the entry would read as misbehaving on every run
whatever the suite did.

The validator deliberately uses the same case-insensitive comparison the scorer
uses, so it cannot reject a manifest that would in fact score correctly:
`expect: "Caught"` is accepted by both, `expect: "maybe"` by neither. Verified
both ways.

Verified:
  - All six original modes, plus a bad `expect` and a missing per-sabotage
    field, now exit 2 on both the -List and sweep paths, each with an
    actionable message. A valid manifest still exits 0 on -List.
  - Both shipped manifests satisfy every rule now enforced -- 48 entries, zero
    violations -- so nothing that already works is rejected.
  - Full sweep on PowerShell 7 and Windows PowerShell 5.1: 9/9 behaved as
    declared, exit 0, no leftover backups, clean tree. Parses clean on both.
  - check-encoding.ps1 passes (570 files); check-commit-scope.ps1 -Staged -Type
    fix reports no cross-crate span.

README-sabotage.md now documents the exit codes as a table and states that a
bad manifest is always a reported exit 2, never a raw PowerShell error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 5, 2026 23:10
@MikeGrier

Copy link
Copy Markdown
Owner Author

Both findings from this round fixed in 7886a60. Taken as reported, and the class turned out to be six failure modes rather than two, so all six are fixed together.

The framing was right and is worth stating sharply: this was a contract the code stated and did not keep. Exit-WithMessage's own comment has said since the original file that "a diagnostic tool reporting a bad manifest must not take the caller's session down with it" -- while $ErrorActionPreference = 'Stop' meant an unguarded Resolve-Path or ConvertFrom-Json did exactly that.

Measured before, across both invocation paths:

missing manifest file  -List=1  sweep=1   raw Resolve-Path error
malformed JSON         -List=1  sweep=1   raw ConvertFrom-Json error
missing package        -List=1  sweep=1   "The property 'package' cannot be found"
missing sabotages      -List=1  sweep=1   "The property 'sabotages' cannot be found"
empty sabotages        -List=0  sweep=2   <-- -List reported SUCCESS
bad root               -List=1  sweep=1   raw Resolve-Path error

That -List=0 is the one I would not have predicted: listing a manifest with nothing in it exited 0, because the -List branch returns before the emptiness check. After the fix every mode is exit 2 on both paths:

No manifest at: .scratch\does-not-exist.json

This manifest is not valid JSON:
  ...\bad-json.json
The parser said:
  Conversion from JSON failed with error: Invalid character after parsing
  property name. Expected ':' but got: v. Path 'package', line 1, position 22.

This manifest declares no 'package':
  ...\no-package.json
It is required unless 'testArgs' supplies the cargo command instead.
Without it the sweep would run 'cargo test -p  --locked' and fail on
an empty package name well after the baseline started.

Your point about the misleading filter message is handled specifically: an empty sabotages now reports "this manifest's 'sabotages' array is empty" rather than "no sabotage matches name filter '*'", which sent the reader to look at a filter they never set.

I extended it to per-sabotage fields for the same reason -- every field in the README's format table is required, and under Set-StrictMode a missing one surfaced as a property error against a line of this script. expect is also checked to be caught/survives, since an unrecognised value is not inert: it is compared for equality when scoring, so it could never match and the entry would read as misbehaving on every run whatever the suite did.

One deliberate subtlety: the validator uses the same case-insensitive comparison the scorer uses, so it cannot reject a manifest that would in fact score correctly. expect: "Caught" is accepted by both; expect: "maybe" by neither. I checked both directions, because a validator stricter than the thing it guards is its own defect.

Verified: all eight modes exit 2 on both paths with actionable messages; a valid manifest still exits 0 on -List; both shipped manifests satisfy every newly-enforced rule (48 entries, zero violations), so nothing that already works is rejected; full sweep on PowerShell 7 and 5.1 still 9/9, exit 0, clean tree. The README now documents the exit codes as a table.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new harness has a couple of confirmed behavioral/reporting issues (including a -List side-effect/behavior mismatch and an unhandled throw path) that should be corrected before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

tools/run-sabotage.ps1:159

  • Get-RepoRoot uses throw when not inside a git repository. With $ErrorActionPreference = 'Stop' this becomes an unhandled terminating error (stack trace) and can abort a calling script/session, which conflicts with the script's own goal of reporting invocation/manifest problems via Exit-WithMessage with a specific exit code.
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/run-sabotage.ps1 Outdated
Comment on lines +446 to +472
if (-not $OutputDirectory) { $OutputDirectory = Join-Path $repoRoot '.scratch\sabotage' }
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null

# Pre-patch copies live in their own subdirectory, so the clearing of stale
# transcripts below cannot reach them, and so a leftover here is unambiguous.
$backupDirectory = Join-Path $OutputDirectory 'restore'
New-Item -ItemType Directory -Force -Path $backupDirectory | Out-Null

# A leftover backup means the previous run did not get to restore its target,
# so that copy may be the only surviving version of the file -- and under
# -AllowDirty it may hold uncommitted work that exists nowhere else. Refusing
# to start is what makes the "a file here means an interrupted run" claim load
# bearing: without it, the next sweep would quietly overwrite the evidence it
# tells the reader to look for.
$leftover = @(Get-ChildItem -LiteralPath $backupDirectory -File -ErrorAction SilentlyContinue)
if ($leftover.Count -gt 0) {
Exit-WithMessage (@(
"Pre-patch backups from an earlier run are still present:"
($leftover | ForEach-Object { " $($_.FullName)" })
"That run was interrupted before it could restore its target, so each of"
"these may be the only copy of the file it names -- under -AllowDirty,"
"including uncommitted work that is in no commit. Compare each against its"
"target and copy it back if the target is still sabotaged, then delete it."
"This sweep will not start while they are here, because it would overwrite"
"them."
) -join "`n") 2
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in ca638c0 -- and this one is the sharpest catch of the six rounds, because it is a claim I wrote one round ago that the code did not keep.

Round four moved the transcript clearing after the -List exit, and I added to the README: "Listing a manifest with -List writes and deletes nothing." That was false. The output directory and its restore/ subdirectory were still created before the listing path, and the leftover-backup guard also ran before it -- so -List both wrote to disk and could be refused outright because an earlier run had been interrupted. As you say, that is exactly when someone wants to read the manifest.

All of that setup now happens after the -List exit. Verified all three properties:

=== A. -List must create NOTHING ===
before: .scratch\sabotage exists = False
  -List exit=0
after : .scratch\sabotage exists = False

=== B. -List must work despite a leftover backup ===
  [manifest lists normally]
  -List exit=0

=== C. a real sweep must still refuse ===
  Pre-patch backups from an earlier run are still present: ...
  sweep exit=2
  backup preserved = True

The guard's message now also mentions that -List still works meanwhile.

Sixth PR #64 review round. Both findings are defects this PR introduced, and
the first is a claim I wrote one round ago that the code did not keep.

1. -List had side effects and could be refused.

Round four moved the transcript clearing after the -List exit and the README
gained the line "listing a manifest with -List writes and deletes nothing".
That was not true. The output directory and its restore/ subdirectory were
still created before the listing path, so -List wrote to disk; and the
leftover-backup guard also ran before it, so -List could be refused outright
because an earlier run had been interrupted -- which is precisely the moment
someone wants to read the manifest.

All of that setup now happens after the -List exit. Verified: with no output
directory present, -List leaves none behind; with a backup planted, -List still
lists and exits 0 while a real sweep still refuses with exit 2 and the backup
still on disk afterwards.

2. Get-RepoRoot threw instead of reporting.

The same defect class as the fifth round's manifest failures, missed because
that sweep looked for Resolve-Path and ConvertFrom-Json and not for `throw`.
Under $ErrorActionPreference = 'Stop' it printed a stack trace and propagated
into the caller. Now a reported exit 2 saying what to do about it. Swept again
for the whole class: this was the only remaining `throw` in the file.

Verified:
  - -List creates nothing, and is not blocked by a leftover backup, while the
    sweep still is.
  - Run from a directory outside any git repository, the script now prints
    three lines of explanation and exits 2 rather than a stack trace.
  - Full sweep on PowerShell 7 and Windows PowerShell 5.1: 9/9 behaved as
    declared, exit 0, no leftover backups, clean tree. Parses clean on both.
  - check-encoding.ps1 passes (570 files); check-commit-scope.ps1 -Staged -Type
    fix reports no cross-crate span.

README-sabotage.md now states the -List guarantee in the form the code actually
implements -- inert, and never blocked by a pending recovery -- and lists
running outside a repository among the reported exit-2 conditions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 5, 2026 23:22
@MikeGrier

Copy link
Copy Markdown
Owner Author

Also fixed the suppressed comment from this round (tools/run-sabotage.ps1:159, Get-RepoRoot using throw) in ca638c0.

Correct, and it is the same defect class I fixed one round earlier -- my round-5 sweep looked for Resolve-Path and ConvertFrom-Json and never grepped for throw, so this one survived a pass that was specifically about this exact problem. A blast-radius sweep that searches for the instances rather than the class is how that happens.

Under $ErrorActionPreference = 'Stop' it printed a stack trace and propagated into the caller. Now reported:

Not inside a git repository, so there is no repository root to
resolve sabotage targets against or to check them for cleanliness.
Run this from within the working tree you mean to sweep.
exit=2

Verified by running the script from a directory outside any git repo (confirming first that git rev-parse --show-toplevel exits 128 there). I re-swept for the whole class afterwards: this was the only remaining throw in the file.

Both fixes re-verified with a full sweep on PowerShell 7 and Windows PowerShell 5.1: 9/9 as declared, exit 0, no leftover backups, clean tree; parses clean on both; encoding gate 570 files clean.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The harness has a few correctness/robustness issues (notably timeout input validation and byte-faithful backup/restore) that should be addressed before relying on it for safety-critical sweeps.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

tools/run-sabotage.ps1:155

  • Invoke-Bounded ultimately calls $process.WaitForExit($Seconds * 1000). If -TimeoutSeconds or -BuildTimeoutSeconds is 0/negative, this becomes a runtime error (and breaks the script's "report, don't throw" contract). Add an explicit early validation that exits 2 via Exit-WithMessage.
    tools/run-sabotage.ps1:319
  • Format-Patch currently prints an empty + line and then + (removed) when the replacement is empty, because the foreach ($line in $Replace -split "n")runs before theIsNullOrEmpty` check. That makes the injected patch display noisier than needed for deletion sabotages.
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread tools/run-sabotage.ps1 Outdated
Comment on lines +735 to +749
$backup = Join-Path $backupDirectory ($stem + '.' + (Split-Path -Leaf $target) + '.bak')
[System.IO.File]::WriteAllText($backup, $original, $utf8NoBom)

try {
# Inside the guarded region, not before it. A write that throws part-way
# through -- having already truncated the file -- would otherwise never
# reach the `finally` that restores it, and the tool's whole promise is
# that it leaves the tree as it found it.
[System.IO.File]::WriteAllText($target, $patched, $utf8NoBom)
$run = Invoke-Sabotaged -CargoArgs $testArgs -WorkingDirectory $repoRoot `
-TranscriptPath $transcript -BuildSeconds $BuildTimeoutSeconds -TestSeconds $TimeoutSeconds
}
finally {
[System.IO.File]::WriteAllText($target, $original, $utf8NoBom)
if ([System.IO.File]::ReadAllText($target) -ne $original) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 7d60e29 -- the diagnosis is right, and the suggested remedy is the one thing I could not take, because File.Copy introduces a worse defect. Both halves below.

The defect you found is real and I reproduced it end to end. Backup and restore went through ReadAllText/WriteAllText and the verification compared decoded text, so a UTF-8 BOM was silently dropped and the check passed anyway. Run against the pre-fix script:

before: 35 bytes, BOM=True,  sha=F56BFC387AAEA8C3...
        "All 1 sabotages behaved as declared."  exit 0
after : 32 bytes, BOM=False, sha=00B8502FBFFF65FD...
BYTE-IDENTICAL RESTORE: False

It reported success while permanently modifying the file and then deleting the backup. Latent here (570 tracked files, 0 with a BOM) but the tool patches whatever a manifest names.

File.Copy cannot be the fix, though: it preserves LastWriteTime. A restored file would carry its original timestamp -- older than the artifact cargo had just built from the patched source. Cargo compares those mtimes, decides the crate is up to date, and keeps the sabotaged binary in the build cache. The next cargo test -- the next sweep's baseline, or a person's -- then runs against sabotaged code.

I know because I implemented your suggestion first and it bit immediately: an unrelated windows-placement-probe test began failing on a clean tree and kept failing until the source was touched. The failing assertion was precisely the one the sweep's last sabotage inverts, which is what identified the cause:

source mtime : 2026-09-05T19:21:39   <- restored, original timestamp
binary mtime : 2026-09-05T19:41:09   <- built from the PATCHED source
source OLDER than binary (=> cargo skips rebuild): True

WriteAllBytes is equally byte-exact and stamps the file now, so cargo rebuilds. Regression test, which the File.Copy version fails: two consecutive sweeps, both green, and cargo test -p windows-placement-probe afterwards recompiles and reports 257/257. Re-checked that BOM fidelity still holds with the new restore -- byte-identical, SHA equal, BOM intact.

Verification hashing both files with SHA-256 replaces the text comparison, as you suggested.

…timeout

Seventh PR #64 review round. Three findings, all taken -- and taking the first
one naively introduced a fourth, worse defect, which is also fixed here.

1. Restore was not byte-faithful.

Backup and restore went through ReadAllText/WriteAllText, and the verification
compared decoded TEXT. That round trip is lossy: ReadAllText strips a UTF-8 BOM
and WriteAllText with a no-BOM encoder does not put it back, so a BOM'd file
came back three bytes shorter -- while the comparison passed and the backup was
then deleted. Measured end to end against the pre-fix script: a 35-byte BOM'd
file was restored as 32 bytes, BOM gone, and the sweep reported "All 1
sabotages behaved as declared", exit 0. Latent here (570 tracked files, 0 with
a BOM) but the tool patches whatever a manifest names, and "restores what it
patched" has to mean the bytes.

Backup and restore now move raw bytes, and the verification hashes both files
with SHA-256 instead of comparing text.

2. But NOT with File.Copy, which is what the review suggested -- that leaves a
   sabotaged binary in the build cache.

File.Copy preserves the source's LastWriteTime, so a restored file carries its
ORIGINAL timestamp, older than the artifact cargo had just built from the
patched source. Cargo compares those mtimes, concludes the crate is up to date,
and keeps the SABOTAGED binary. The next `cargo test` -- the next sweep's
baseline, or a person's -- then silently runs against sabotaged code.

Found by doing it: after a File.Copy-based sweep, an unrelated placement-probe
test failed on a clean tree and kept failing until the source was touched. The
failing assertion was the one the sweep's LAST sabotage had inverted, which is
what identified it. Both restores now use WriteAllBytes, which is equally
byte-exact and stamps the file now, so cargo rebuilds.

3. A timeout of zero or less was accepted.

Both bounds reach WaitForExit($Seconds * 1000). Zero returns immediately
without waiting, so every phase would be reported as a hang and every sabotage
as `caught` -- a fully green sweep proving nothing, which is the one outcome
this tool exists to make impossible. A negative value throws instead, breaking
the report-don't-throw contract. Both measured; both now rejected with exit 2
before anything runs.

4. Format-Patch printed an empty '+' line before '+ (removed)'.

Splitting an empty replacement yields one empty element, so deletion sabotages
rendered both. Five of the 48 entries across the two shipped manifests use the
deleting form, so this was every deletion's patch display.

Verified:
  - BOM'd file through a full patch/run/restore: byte-identical, SHA-256 equal,
    BOM intact. The pre-fix script on the same input: 32 bytes, BOM stripped,
    still reporting success.
  - Two consecutive sweeps: both green. Before the mtime fix the SECOND
    sweep's baseline failed, because the first had left a sabotaged binary
    cached. `cargo test -p windows-placement-probe` afterwards recompiles and
    reports 257/257.
  - -TimeoutSeconds 0, -TimeoutSeconds -5 and -BuildTimeoutSeconds 0 each exit
    2 with an explanation.
  - Format-Patch renders '+ (removed)' alone for a deletion and the replacement
    lines otherwise.
  - Full sweep on PowerShell 7 and Windows PowerShell 5.1: 9/9 as declared,
    exit 0, no leftover backups, clean tree. Parses clean on both.
  - check-encoding.ps1 passes (570 files); check-commit-scope.ps1 -Staged -Type
    fix reports no cross-crate span.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 5, 2026 23:47
@MikeGrier

Copy link
Copy Markdown
Owner Author

Both suppressed comments from this round are also fixed in 7d60e29.

tools/run-sabotage.ps1:155 -- timeout validation. Confirmed, and the zero case is worse than the negative one. Measured:

WaitForExit(0) on a running process -> False       [=> reported as HUNG]
WaitForExit(-1000)                  -> THREW MethodInvocationException

So -TimeoutSeconds 0 does not disable the bound, it makes every phase time out instantly -- and this tool reports a timed-out phase as caught. A sweep with a zero bound would therefore have returned every sabotage caught, exit 0: a perfectly green result proving nothing, which is the single outcome the harness exists to make impossible. The negative case is the report-don't-throw violation you name. Both now exit 2 before anything runs:

-TimeoutSeconds must be at least 1 second, and was 0.
A bound of zero does not disable the timeout; it makes every phase
time out instantly, which this tool would report as every sabotage
being caught -- a green sweep that proved nothing. Pass a real budget instead.

Verified for -TimeoutSeconds 0, -TimeoutSeconds -5, and -BuildTimeoutSeconds 0.

tools/run-sabotage.ps1:319 -- Format-Patch empty replacement. Confirmed, and it is live rather than hypothetical: splitting an empty replacement yields one empty element, so the old order printed + and then + (removed), and 5 of the 48 entries across the two shipped manifests use the deleting form. So this was every deletion sabotage's patch display. Now:

    --- injected patch ---
    - self.doorbell.signal();
    + (removed)

with the substitution case unchanged.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Manifest validation still allows some invalid find/replace shapes to degrade into confusing outcomes or unintended destructive patches instead of failing fast with a clear exit-2 error.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

tools/run-sabotage.ps1:469

  • Manifest-entry validation only checks that find/replace properties exist, not that they have a valid shape/value. In particular, an empty/null find produces a confusing MANIFEST STALE: pattern found ... times result, and a null replace is currently treated like deletion (because -join yields an empty string), which is likely to be an accidental destructive patch.

Consider rejecting empty find and null replace up-front with an exit-2 manifest error so invalid manifests fail fast with a clear message.

  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

… files

The premise was wrong, and most of this PR's review findings were consequences
of it rather than independent bugs.

Patching the developer's own files means every sabotage needs a backup, a
restore, a check that the restore worked, a guard against running on a dirty
tree, a containment check, and a recovery path for when any of that is
interrupted. Each is a chance to damage work that was never in a commit. Of the
eighteen defects found across seven review rounds on this PR, eight were in that
machinery -- and every data-loss risk was. The last one was the sharpest: a
byte-exact restore via File.Copy carried the original mtime, so cargo judged the
crate up to date against an artifact built from PATCHED source and left the
sabotaged binary in the developer's build cache.

The sweep now runs against a copy, the way cargo-mutants does. The real tree is
read and never written.

WHAT THIS DELETES. The backup files and their directory, the restore, the
restore verification, the leftover-backup guard, the dirty-tree guard, the git
exit-code check that guard needed, the -AllowDirty switch and the whole notion
of waiving cleanliness, and exit code 3. What remains is the part that was
always the point: manifest validation, exactly-once matching, the build/test
phase split, doctest reclassification, and transcripts.

WHAT IT ADDS. Sync-Tree, which refreshes the copy from the working tree at the
start of a run. Files are enumerated by git -- tracked plus untracked-not-
ignored -- so target/ (28 GB here) and the scratch directory stay out without a
second exclusion list to drift from .gitignore. Only files whose contents differ
are copied, which is what keeps the copy's build warm; files the source no
longer has are deleted, so a rename cannot leave a stale twin to compile.

The copy builds into its OWN target directory, set through CARGO_TARGET_DIR
rather than --target-dir so a manifest's testArgs cannot redirect a sabotaged
build into the developer's real one.

BEHAVIOUR CHANGES, both improvements:
  - A dirty tree is now swept exactly as it stands. The copy is made from the
    working tree, not from a commit, so uncommitted edits are what get measured
    -- usually the code whose guards you are asking about. -AllowDirty is gone
    because there is nothing left to waive.
  - Getting a bug in this tool wrong now costs a scratch directory, not work.

Verified:
  - The real tree is untouched: all 570 tracked files fingerprinted by content
    hash AND mtime before and after a sweep, sets identical.
  - The copy is byte-identical to the real tree: 570 files compared, 0 differ.
  - placement-probe, 9 entries: 9/9 as declared, exit 0, on PowerShell 7 and on
    Windows PowerShell 5.1. Parses clean on both.
  - waitable-queues, 39 entries: 34 as declared; the other 5 are MANIFEST STALE
    against main's own source and are a pre-existing defect in main, not a
    regression here -- their patterns occur 0 times in the real tree, measured
    directly, and the copy is proven identical to it. Reported separately.
  - Uncommitted work is swept: an uncommitted edit that breaks a test turns the
    baseline red, proving the copy reflects the working tree rather than HEAD.
  - Cold 69s, warm 49s for the 9-entry manifest; scratch is 7 MB of tree and
    379 MB of target.
  - check-encoding.ps1 passes (570 files).

README-sabotage.md is rewritten to match: Safety now describes the copy rather
than a backup protocol, and its timing claim is corrected -- the 39-entry sweep
measures 853s, of which twelve hangs at the default 60s bound are 720. It had
said "about three minutes", which is not reachable at that bound.

Marked ! because -AllowDirty and exit code 3 are removed. Nothing in the
repository passes either; both shipped manifests are unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 6, 2026 00:21
@MikeGrier MikeGrier changed the title Add the sabotage harness that two shipped manifests already cite Add the sabotage harness, sweeping a copy of the tree Sep 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It introduces a large new execution harness (process management + filesystem mutation in a working copy) that should get final human validation on Windows in addition to the small doc/message fixes noted.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread tools/README-sabotage.md
| `expect` | yes | `caught` for a defect, `survives` for a control. |
| `why` | yes | What breaks, and why the suite should or should not notice. This is the part a future reader needs; the patch only says what changed. |
| `find` | yes | Lines to replace. Must match **exactly once**. |
| `replace` | yes | Replacement lines. `[""]` deletes. |
Comment thread tools/run-sabotage.ps1
Comment on lines +645 to +650
"Two sabotages in this manifest reduce to the same file name stem,"
"so they would share a transcript and a restore backup:"
" $($stemOwners[$stem])"
" $($sabotage.name)"
"Both become '$stem'. Rename one so the two differ by more than"
"punctuation."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants