test(gate): establish the locale precondition instead of naming one

Review found the byte-versus-character witness asserting something
adjacent to its contract. It set `LC_ALL=C.UTF-8` and assumed the
locale took effect. Locale names beyond `C` and `POSIX` are
implementation-defined, so where that one is absent the shell falls
back to byte semantics --- and then the character-counting mutant
counts bytes too, agrees with the fix, and the row passes while
proving nothing. M-G-6 was killable here and unkillable elsewhere,
which is the same as not having it.

The locale is now chosen by BEHAVIOUR. Candidates come from `locale -a`
so the set reflects what is installed, and each is probed through the
same `/bin/sh` the gate runs under, asking `${#x}` on a two-byte
character and requiring `1`. No qualifying locale is a loud panic
naming what was tried, never a skip: a skip would be indistinguishable
from a pass, which is the failure mode this replaces.

M-G-8 proves the fix in three legs, because the hazard lives in the
environment rather than the code:

  8a  mutant gate, probed locale  -> the row fails, and the
      exact-boundary row still passes
  8b  SAME mutant gate, locale forced to `C` -> the row passes.
      The defect reproduced rather than argued.
  8c  no candidate can qualify -> panic naming the candidates

Also marks framing revision 6 approved and records M-G-8 in the ledger.

Gates: all nine green under `env -u TMPDIR`, log 20260813T182020Z.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
Levi Neuwirth 2026-08-13 20:25:00 +02:00
parent 8e1f03a4a0
commit 3a7e3790a1
No known key found for this signature in database
3 changed files with 96 additions and 10 deletions

View File

@ -331,6 +331,26 @@ from #171 and #215.
rejection-cleanup row. Propagation is observed in a **spawned
child**, because the gate exporting a variable would only prove the
gate can export a variable.
- **`M-G-8` is the one that proves `M-G-6` is not vacuous**, and it
needs three legs because the hazard is in the *environment*, not the
code. The multibyte row originally set `LC_ALL=C.UTF-8` and assumed
it took: locale names beyond `C` and `POSIX` are
implementation-defined, so on a machine lacking that locale the shell
falls back to **byte** semantics — and then the character-counting
mutant counts bytes too, agrees with the fix, and the row passes
while proving nothing.
- **8a** — mutant gate, locale chosen by the probe → the row
**fails**, and the exact-boundary row still passes.
- **8b** — *same mutant gate*, locale forced to `C` → the row
**passes**. This is the defect itself, reproduced rather than
argued: the only difference between a real witness and a vacuous
one is whether the shell counts characters.
- **8c** — no candidate can qualify → the helper **panics** naming
what it tried. A skip here would be indistinguishable from a pass.
The locale is therefore selected by **behaviour**: candidates come
from `locale -a`, and each is probed through the same `/bin/sh` the
gate runs under, asking `${#x}` on a two-byte character and requiring
`1`.
- **Proved against the live hazard:** `/tmp/.git` is still present on
this machine, and the tests it reddened now pass with **no override**.
- **Gates:** `./scripts/gate --acceptance gate_script_acceptance`, run

View File

@ -1,12 +1,21 @@
# `scripts/gate` — per-worktree build isolation, and one gate suite
**Status: revision 6 — AWAITING APPROVAL.** Revision 6 extends the
isolation contract to `TMPDIR` (§2a below) and is the only unapproved
part of this document; everything else is as approved. It is a
*widening of an existing responsibility*, not a new feature: §2 already
owns "what the gate isolates", and `TMPDIR` was simply missing from
that list — which is how a stray `/tmp/.git` came to redden a gate run
on an unrelated lane.
**Status: revision 6 — APPROVED and IMPLEMENTED.** Revision 6 extends
the isolation contract to `TMPDIR` (§2a below). It is a *widening of an
existing responsibility*, not a new feature: §2 already owns "what the
gate isolates", and `TMPDIR` was simply missing from that list — which
is how a stray `/tmp/.git` came to redden a gate run on an unrelated
lane.
**Approved after three review rounds, all of which turned on evidence
rather than design.** Revisions 6a–6c tightened the socket budget to
the Darwin floor, replaced an existence-only ancestor check with one
that honours marker types, made the traversal canonical, moved the
guard rows onto the exact boundary, and — last — required the
byte-versus-character row to *establish* its locale precondition
instead of naming one. Each correction was a witness asserting
something adjacent to the contract while appearing to assert the
contract itself.
**Previously, revision 5. Approved at revision 4 and IMPLEMENTED; revision
5 records two safety defects review found in the implementation.**
@ -425,7 +434,7 @@ under real parallel load, direnv is the escalation.
---
## 2a. `TMPDIR` isolation (revision 6, AWAITING APPROVAL)
## 2a. `TMPDIR` isolation (revision 6, APPROVED)
**The gap.** §2 lists what a gate run isolates: the target directory and
five ambient roots. `TMPDIR` was not on that list, so

View File

@ -834,13 +834,20 @@ fn the_socket_path_guard_counts_bytes_not_characters() {
bytes (must fail), path {path}"
);
// **Chosen by BEHAVIOUR, not by name.** Locale names beyond `C` and
// `POSIX` are implementation-defined and an unrecognized value has
// unspecified behaviour, so setting `LC_ALL=C.UTF-8` and hoping is
// not a precondition — where that locale is absent the shell would
// fall back to byte semantics and the character-counting mutant
// would pass, silently.
let locale = char_counting_locale();
let out = std::process::Command::new(gate())
.arg("--self-test")
.current_dir(repo_root())
.env("PMACS_GATE_TARGET_ROOT", root.path())
.env("PMACS_GATE_ALLOW_ANCESTOR_MARKER", "1")
.env("LC_ALL", "C.UTF-8")
.env("LANG", "C.UTF-8")
.env("LC_ALL", &locale)
.env("LANG", &locale)
.env_remove("TMPDIR")
.output()
.expect("run gate");
@ -853,6 +860,56 @@ fn the_socket_path_guard_counts_bytes_not_characters() {
);
}
/// A locale under which **`/bin/sh` counts CHARACTERS** — verified by
/// asking that very shell, not by trusting a name.
///
/// `${#x}` on a two-byte character answers `1` under a working UTF-8
/// locale and `2` under `C`. That difference is the entire subject of
/// the row this serves, so the row must establish it as a precondition
/// rather than assume it: otherwise, on a machine without the named
/// locale, the shell counts bytes, the mutant agrees with the fix, and
/// the test passes while proving nothing.
///
/// Candidates come from `locale -a`, so this reflects what is actually
/// installed. **Fails loudly when none qualifies** — a skip here would
/// be indistinguishable from a pass.
fn char_counting_locale() -> String {
let installed = std::process::Command::new("locale")
.arg("-a")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
let candidates: Vec<String> = ["C.UTF-8".to_owned(), "C.utf8".to_owned()]
.into_iter()
.chain(
installed
.lines()
.filter(|l| l.to_ascii_lowercase().contains("utf"))
.map(str::to_owned),
)
.collect();
for cand in &candidates {
let probe = std::process::Command::new("/bin/sh")
.arg("-c")
.arg("x=é; echo ${#x}")
.env("LC_ALL", cand)
.env("LANG", cand)
.output();
if let Ok(out) = probe
&& String::from_utf8_lossy(&out.stdout).trim() == "1"
{
return cand.clone();
}
}
panic!(
"no installed locale makes /bin/sh count characters, so the \
byte-vs-character distinction this row exists to test cannot \
be established here. Tried: {candidates:?}"
);
}
/// A root whose full path is exactly `total` bytes.
fn root_of_len(total: usize) -> tempfile::TempDir {
let base = short_root_base();