Skip to content

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

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

Add the sabotage harness, sweeping a copy of the tree#64
MikeGrier wants to merge 16 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 Outdated
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: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 Outdated
| `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."
Mike Grier and others added 2 commits September 5, 2026 21:20
…thing

Found by the harness this PR adds, on its first full run against main's own
source: five of the 39 entries reported MANIFEST STALE, meaning their `find`
patterns occurred zero times and those sabotages were never applied at all.

That is the finding the tool exists to make visible. Five guards on a published
crate were silently unverified, and a green sweep would never have said so --
which is why a pattern that no longer matches is reported as a manifest problem
rather than counted as caught.

Not a regression from this PR: the sweep's working copy was confirmed
byte-identical to the real tree (570 files, 0 differing) and the patterns were
then measured directly against the real files, with the same result.

The manifest had drifted from the source in specific ways:

  the final drain returns nothing            spsc's finish() now calls take(),
                                             not pop()
  a best-effort push may take a reserved     the room check now compares against
    slot                                     u64::from(reserved)
  reserve does not check for room            the claim is now read through
                                             L::Word::load rather than a direct
                                             atomic load
  redeeming does not release the             claim_word and its position helpers
    reservation                              are now generic over the layout L,
  dropping a reservation does not return     and advance::<L>() replaced
    the slot                                 wrapping_add(1)

Each pattern is re-anchored to what the source actually says, and each keeps its
original intent -- the `why` fields are unchanged, because what these sabotages
are meant to break has not changed, only how the code spells it.

Verified: all 39 patterns now match exactly once, and a full sweep reports 39/39
behaving as declared, exit 0. The five are genuinely CAUGHT rather than merely
matching -- four by test failures and one by a hang -- so the guards they
exercise are real.

Typed chore rather than fix: this repairs test tooling that ships inside the
crate directory, changes nothing a consumer of the crate can observe, and should
not cut a release.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rceable

Two changes, and the second is a correctness bug the first exposed.

1. The bound is derived from the baseline instead of fixed at 60 seconds.

Hangs dominate a sweep's wall clock -- 720 of 853 seconds on the 39-entry
waitable-queues manifest -- so the bound is what moves the total. A fixed
default is wrong in both directions: too tight on a loaded machine or a large
suite, where a slow-but-finite run is scored as CAUGHT and quietly inflates the
result; too loose on a fast one, where every hang pays the difference.

The baseline already runs the unmodified suite first, so its measured TEST
duration is the best available statement of how long this suite legitimately
takes on this machine. The bound is now
`max(-TimeoutFloorSeconds, -TimeoutMultiplier x baseline)`, default
`max(15, 3x)`, and the sweep prints what it derived and why.

Derived from the test phase alone, not build-plus-test: the bound governs test
execution, and the build is the volatile part -- the first run against a fresh
copy pays a cold build, which would inflate the bound on the one run least able
to judge what is normal. Measured: 25s build-plus-test cold against 3s for the
tests.

Raising it where it is needed, rather than everywhere, is now expressible three
ways: -TimeoutSeconds for a run, a manifest-level `timeoutSeconds` for a suite,
and a per-sabotage `timeoutSeconds` for the one entry that legitimately runs far
longer than the rest. A per-entry value only ever RAISES the sweep's bound,
because the reason to lower one is speed and the cost of being wrong is a false
`caught`.

2. WaitForExit(ms) did not enforce the bound at all.

Found while measuring the above: a sweep sat on a single hung sabotage for 31
MINUTES against a 60-second bound. The kill was never reached, and the run
resumed the moment that test binary was killed by hand -- so this was not a slow
sweep, it was a stalled one, and a tool whose whole job is to detect hangs must
not be hangable by one.

cargo's stdout and stderr are redirected to files and the test binary cargo
spawns inherits those handles. .NET's timed WaitForExit waits for the redirected
streams to reach EOF as well as for the process, so the wait outlives the bound
for as long as the grandchild holds the handles -- for a hung test, forever.
Replaced with a deadline poll on HasExited, which only asks whether the process
object is signalled and never touches the streams, so it cannot overrun.

Verified:
  - 39-entry waitable-queues manifest: 39/39 as declared, exit 0, in 335s
    against 853s before, with no stray cargo or test processes left behind and
    the real tree untouched.
  - 9-entry placement-probe manifest: 9/9 as declared, exit 0, on PowerShell 7
    and Windows PowerShell 5.1. Parses clean on both.
  - The derived bound is reported: "Baseline is green in 4s. Hang bound: 15s
    (3x the 4s baseline, floor 15s)."
  - -TimeoutMultiplier 0, -TimeoutFloorSeconds 0 and an explicit
    -TimeoutSeconds 0 are each rejected with exit 2; a manifest or per-entry
    timeoutSeconds below 1 likewise.
  - check-encoding.ps1 passes (570 files).

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

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 README has a couple of concrete doc/schema mismatches (notably a fixed “60s” claim vs derived default timeout behavior) that should be corrected before merging.

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

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

Comment thread tools/README-sabotage.md Outdated

**Build and test are timed separately, and the split is what keeps the test
bound tight.** A hang is what a lost wakeup looks like and it happens during
test execution, so that phase gets a short bound (60s). A build is merely slow
Comment thread tools/README-sabotage.md
Comment on lines +210 to +217
| `timeoutSeconds` | no | Hang bound for the whole sweep, overriding the derived one. `-TimeoutSeconds` still wins over it. |
| `name` | yes | Unique; also the `-Name` filter key and the transcript filename. |
| `file` | yes | Source to patch, relative to `root`. |
| `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. |
| `timeoutSeconds` | no | Hang bound for this entry alone, when it legitimately runs far longer than the rest. Only ever raises the sweep's bound. |
…own copy

Four findings from a review of the copy-based rewrite. The first is the serious
one, and it is a defect that rewrite introduced.

1. CARGO_TARGET_DIR was set on the CALLER's session, and never restored.

A .ps1 runs in the hosting PowerShell process, so `$env:CARGO_TARGET_DIR = ...`
mutated the developer's session and outlived the script -- every later cargo
command in that session, in this repository or any other, would have built into
this tool's scratch directory. Measured: the variable is still set in the
calling session after the script exits. That is precisely the harm the rewrite
was for, arriving by a different route, and it contradicted both the README's
"your build cache is not touched either" and the script's claim that the worst
case is a scratch directory to delete.

The comment justifying the environment variable was also backwards. It said
setting it there kept it out of the argument vector "so a manifest cannot
accidentally aim a sabotaged build at the developer's real target directory".
Cargo's precedence is CLI flag OVER environment variable, so a `testArgs`
carrying --target-dir would have overridden it, not the reverse. Measured that
too: with CARGO_TARGET_DIR set and --target-dir passed, only the CLI directory
was created.

The directory is now passed as --target-dir on each cargo command line, which
touches nothing outside the process it launches and wins over any
CARGO_TARGET_DIR the developer already has. The one thing a CLI flag cannot
survive is a manifest passing its own, which cargo rejects as a duplicate, so
that is refused up front with a message that says what it is.

2. Sync-Tree silently dropped any file whose name git quotes.

core.quotePath defaults on, so a path containing a non-ASCII byte is emitted as
`"crates/.../zz-caf\303\251.txt"`. Test-Path -LiteralPath on that is false, so
the file was neither copied into the working copy NOR recorded as wanted: the
copy diverged from the real tree by exactly the files nothing could see, and the
symptom would have been a red baseline blamed on the suite. Both enumerations
now run with -c core.quotePath=false.

Latent rather than active -- no such file exists here today -- but it also
silently weakened the "570 files compared, 0 differ" check, which enumerated
through the same quoting.

3. An -OutputDirectory git does not ignore made the copy copy itself.

What keeps the working copy out of its own enumeration is that `.scratch/` is
ignored, which covers the default and nothing else. Pointed anywhere else inside
the repository, the second run would enumerate the first run's copy and nest it
one level deeper, marking every nested file as wanted so the deletion pass
preserved it -- unbounded growth that looks like nothing until it does not. Now
refused unless git ignores the location.

4. Two stale comments, both from the last two commits: the bounds guard still
blamed WaitForExit's argument validation for rejecting a zero bound (the guard
is still right, but the poll that replaced that wait classifies zero as an
expired deadline instead), and the README still named a fixed "(60s)" test bound
that the derived one replaced.

Verified:
  - CARGO_TARGET_DIR is empty in the calling session before and after a sweep.
  - An -OutputDirectory of tools\sab-probe is refused with exit 2; a manifest
    whose testArgs passes --target-dir is refused with exit 2.
  - A non-ASCII filename now reaches the copy with matching contents, and is
    removed from the copy on the next sync after the source deletes it.
  - waitable-queues 39/39 as declared, exit 0, 352s; placement-probe 9/9 on
    PowerShell 7 and Windows PowerShell 5.1; parses clean on both.
  - Real tree untouched, no stray cargo or test processes, check-encoding.ps1
    passes (570 files).

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

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

There are a few concrete documentation inconsistencies/ambiguities (and one misleading in-script comment) that should be corrected to avoid future misuse of the harness/manifest format.

Review details

Suppressed comments (4)

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

tools/README-sabotage.md:64

  • The formula uses leading '-' characters (-TimeoutFloorSeconds, -TimeoutMultiplier) inside the max(...) expression. In Markdown this reads like subtraction, and max(15, 3x) is also ambiguous about what is being multiplied. Rephrasing the formula to use bare parameter names and an explicit baseline term is clearer.
    tools/run-sabotage.ps1:920
  • The timeout-derivation comment says the baseline duration includes the build, but Invoke-Sabotaged returns the run/test phase seconds (build seconds are not included). This makes the comment contradict the very next paragraph and can mislead future edits about what baseline.Seconds represents.

tools/README-sabotage.md:218

  • The manifest-format table lists timeoutSeconds twice with the same field name, once for the whole sweep and once for per-entry. This is ambiguous for readers and for anyone validating manifests manually.
| `timeoutSeconds` | no | Hang bound for this entry alone, when it legitimately runs far longer than the rest. Only ever raises the sweep's bound. |

tools/README-sabotage.md:217

  • The docs say replace deletes with [''], but the shipped manifest also uses an empty array ("replace": []) to delete a matched block. Since the script joins the array with newlines, both spellings are equivalent; documenting both avoids confusing manifest authors.
| `replace` | yes | Replacement lines. `[""]` deletes. |
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…ion notes

All documentation, all confirmed against the code rather than taken on report.

1. A comment contradicted the line below it.

The timeout-derivation note said the baseline measures the suite's duration
"including its build", while the very next paragraph said the figure used is the
test phase alone. The second is correct -- $baseline.Seconds carries the test
phase and nothing else -- so the first is deleted and the two blocks are now one
argument instead of two halves separated by nothing.

The same note cited "8s for the tests themselves", which is not a number this
was ever measured at. Corrected to the figures actually recorded on the
placement-probe manifest: 25s for build-plus-test against a cold copy, 3-4s for
the tests alone.

2. `timeoutSeconds` appeared twice in one flat table.

The manifest format table listed every field at both levels in a single run --
`package` and `root` beside `name` and `find` -- which was survivable until this
PR added a field that exists at BOTH levels with different meanings. Split into
a top-level table and a per-sabotage one, which resolves the collision and the
pre-existing ambiguity together, with a line stating plainly that the two
`timeoutSeconds` are different fields.

3. The delete spelling was documented as `[""]` only.

Both `[]` and `[""]` delete: the lines are joined with newlines, so both produce
the empty string. The shipped waitable-queues manifest uses `[]` once and `[""]`
three times, so a reader following the table would have found the manifest
contradicting it.

4. The derived-bound formula read as arithmetic on the parameter names.

`max(-TimeoutFloorSeconds, -TimeoutMultiplier x baseline)` looks like subtraction
in prose, and `max(15, 3x)` did not say what was being multiplied. Rewritten as a
fenced formula with bare names and an explicit `baselineTestSeconds` term, plus a
worked example that matches the line the tool actually prints.

Two rows were added while the tables were being split, both stating behaviour
that was already implemented and verified but undocumented: `sabotages` is
required and must be non-empty, and `testArgs` must not carry `--target-dir`.

Verified: every claim the tables now make was checked against the code that
enforces it; the floor example matches the arithmetic; a sweep of both manifests
still reports as declared, exit 0, including the one entry that uses the `[]`
delete spelling; parses clean on PowerShell 7 and Windows PowerShell 5.1;
check-encoding.ps1 passes (570 files).

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

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 README’s exit-code semantics don’t fully match the script’s behavior (baseline failures also exit 2), and there is also unused code that should be cleaned up.

Review details

Suppressed comments (2)

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

tools/README-sabotage.md:33

  • The exit-code table says exit 2 only means “manifest or invocation is wrong”, but run-sabotage.ps1 also exits 2 when the baseline suite is red. The table should mention the baseline-failed case so callers don’t misinterpret exit 2 as strictly a manifest problem.
    tools/run-sabotage.ps1:870
  • $treeRootPrefix is computed but never used. This looks like leftover code from an earlier version and adds noise to a script that already has a lot of moving parts.
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…ad code

Two findings, both confirmed.

1. The exit-code table under-described exit 2.

It said exit 2 means "the manifest or the invocation is wrong". Enumerated: the
script has 20 exit-2 sites, and 19 of them are exactly that -- but the twentieth
is the baseline suite being red, which is neither. That case is the caller's
tests failing before any sabotage is applied, and a wrapper reading only the code
would have gone looking for a manifest problem it would never find.

The table now names both, and the prose says which message distinguishes them.
Left as one code rather than splitting the baseline case onto its own: that is a
change to a documented contract and worth deciding deliberately rather than in
passing, and the message already says plainly which case it is.

The same paragraph was also missing three exit-2 conditions added during this
PR: an -OutputDirectory git does not ignore, a testArgs carrying --target-dir,
and the timeoutSeconds validations.

2. $treeRootPrefix was computed and never read.

Left over from the copy-based rewrite, where the re-basing was first written
against a tree-root prefix before being changed to use $repoRootPrefix at the
point of use. Three lines of misleading noise in the middle of the path logic,
suggesting a second prefix mattered when only one does. Removed, and the comment
above it now names the prefix that is actually used.

Swept the rest of the script for the same class while there: every other
assigned variable is read.

Verified: a deliberately-broken suite still exits 2 with the baseline-specific
message and transcript path; a normal sweep reports 9/9 as declared, exit 0;
parses clean on PowerShell 7 and Windows PowerShell 5.1; check-encoding.ps1
passes (570 files).

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

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 timeoutSeconds validation currently treats 0 as “not provided” due to truthiness checks, making the documented/reported “must be at least 1” behavior unenforced.

Review details

Suppressed comments (2)

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

tools/run-sabotage.ps1:597

  • Manifest-level timeoutSeconds = 0 will currently bypass validation because the condition uses truthiness (-and $spec.timeoutSeconds). In PowerShell, 0 is $false, so the "must be at least 1" error path becomes unreachable and an explicitly invalid manifest value is silently ignored (contradicting the README text and the error message).

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

tools/run-sabotage.ps1:607

  • Per-sabotage timeoutSeconds = 0 is also not rejected for the same reason: the guard uses $entry.timeoutSeconds as a truthiness check, so 0 skips validation. If 0 is meant to be invalid (as the error message says), the check should treat "present and 0" as an error rather than as "not provided".
foreach ($entry in @($spec.sabotages)) {
    if (($entry.PSObject.Properties.Name -contains 'timeoutSeconds') -and
        $entry.timeoutSeconds -and [int]$entry.timeoutSeconds -lt 1) {
        Exit-WithMessage (@(
                "The sabotage '$($entry.name)' sets timeoutSeconds = $($entry.timeoutSeconds)."
                "It must be at least 1; a bound of zero reports it as caught without running."
            ) -join "`n") 2
    }
}
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…ough

Three defects, one reported and two found by chasing it.

1. A manifest timeoutSeconds of 0 was silently ignored.

PowerShell counts 0 as false, so `-and $spec.timeoutSeconds` short-circuited
before reaching the `-lt 1` check inside it. The error message names zero
specifically -- "a bound of zero reports every sabotage as caught" -- and zero
was the one value that could not reach it. Measured: 0 exited 0 at both the
manifest and per-sabotage level, while -5 was correctly rejected at both,
because a non-zero number is truthy.

The consequence was mild by luck rather than design -- an ignored bound falls
back to the derived one, which is safe -- but an author who typed 0 was told
nothing and got a different bound than the file asked for. Presence is now
tested with `$null -ne`.

2. Manifest validation was skipped whenever -TimeoutSeconds was passed.

The whole block was gated on `$TimeoutSeconds -eq 0`, so validation only ran on
the invocations that were going to use the value. Measured: a manifest declaring
-5 exits 2 normally and exited 0 with `-TimeoutSeconds 30`. A manifest carrying
a nonsense bound would therefore be diagnosed only on the runs not overriding
it, which is the run an author debugging an override is least likely to make.
Validation and use are now separate: every timeoutSeconds in the file is checked
whatever the command line said, and the sweep-wide one is applied only when
-TimeoutSeconds did not already name a number.

3. A bound set by the manifest was reported as "-TimeoutSeconds".

Found while verifying that a manifest's value is actually applied. The source
label was chosen from `$TimeoutSeconds -gt 0`, which is true however the value
arrived, so the sweep credited a flag the runner never passed -- and would have
sent anyone trying to change it to the wrong place. Now attributed to whichever
of the three actually supplied it:

  Hang bound: 40s (the manifest's timeoutSeconds)
  Hang bound: 25s (-TimeoutSeconds)
  Hang bound: 15s (3x the 3s baseline, floor 15s)

Also checked the other truthiness guards in the file for the same trap. None
carry a falsy value that is meaningful-but-invalid the way 0 is here: an empty
`package` is as bad as a missing one and is already reported that way, and an
empty `root` or `testArgs` is properly equivalent to omitting it.

Verified: manifest 0, per-entry 0, manifest -5, and manifest -5 with
-TimeoutSeconds 30 all exit 2; valid values at both levels exit 0 and are
applied with correct attribution; waitable-queues 39/39 in 341s and
placement-probe 9/9 on PowerShell 7 and Windows PowerShell 5.1; caller's
environment untouched; check-encoding.ps1 passes (570 files).

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

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.

🟢 Approval recommended

The harness and documentation are internally consistent with the stated “copy-only” safety model, and the updated manifest patterns match the current source locations they target.

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

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