diff --git a/docs/active-work.md b/docs/active-work.md index 49408c1..f4bece7 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -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 diff --git a/docs/gate-script-framing.md b/docs/gate-script-framing.md index 659a1d9..9ee929d 100644 --- a/docs/gate-script-framing.md +++ b/docs/gate-script-framing.md @@ -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 diff --git a/tests/gate_script_acceptance.rs b/tests/gate_script_acceptance.rs index 59de460..b073978 100644 --- a/tests/gate_script_acceptance.rs +++ b/tests/gate_script_acceptance.rs @@ -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 = ["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();