Skip to content

Commit 7ca3655

Browse files
committed
fix(stage1): five review defects in identity, classification and PS semantics
Verified each against primary source before touching it; all five were real. D1 — a relative OWEN_RUST_CORE was accepted by all three independent implementations. `Path.GetFullPath` reads as "it resolves the path for me", and it does: against whatever directory Owen happened to run in, so the same configuration would select different binaries from different places. That is the ambient resolution D3 exists to forbid. Now rejected up front, exit 2, naming the requirement. The shell test accepts the absolute forms this surface actually receives — the MSYS `/d/a/...` CI passes, `C:\...`, `C:/...`, UNC — because a bare `/*` glob would turn a correct Windows configuration into a usage error; it converts between none of them, as D3 ratified a locator and not a path-translation policy. action.yml is not a fourth implementation: it delegates, and the control proves the forwarding. D2 — the compare verdict was inferred as `childExitCode is null && py is not null && rs is not null ? "divergence" : "execution-failure"`. `child_exit_code` is the D5 RUST-child carrier, so a Python-only bad exit left it null with both outcomes present and stamped "divergence" onto evidence whose own diagnostic beside it said "execution failure". The classification is now passed by the call site, which is the only place that knows. The external vocabulary is unchanged — agreement / divergence / execution-failure — because a new serialized value is a contract the project would owe support for forever. D3 — own-check.ps1 never mapped a spawn failure to exit 2, and its comment asserted the opposite of D3.1 ("maps to the internal-error path"). A candidate that never started is on the locator's side of the seam. Both engine paths now catch it, and the comment says what the code does. D4 — ps1 agreement replayed through `Get-Content -Raw | Write-Output`, a decode-and-re-encode, and emitted no stderr at all. It now replays the reference's raw bytes on both streams, as C# and own-check.sh do. Implemented for the contract, not for today's statistics: CRLF-vs-LF makes agreement rare on Windows now, but a replay that is wrong only when it finally runs is worse than none. D5 — ps1 named $cmpDir as reproduction evidence and then deleted it in `finally`. Pointing a reader at a path and shredding it on the way out is worse than naming nothing. The directory now survives an execution failure, as it already did a divergence. A SIXTH defect, found while proving D4 and fixed with it: `Start-Process -RedirectStandardOutput` is not byte-faithful. Measured on one input, the Python reference wrote 211 bytes and the redirected file held 210 — a blank line silently dropped. Compare claims the engines' public BYTES are identical, so a lossy capture of the reference can manufacture a divergence that does not exist or hide one that does; an agreement reached over a corrupted capture is not an agreement. ps1 now drains both pipes as byte streams, concurrently, the way the C# launcher does. Fixing only the replay would have left the mechanism broken underneath it. Evidence. `tests/helpers/stage1_stub.rs` is a committed, controllable native candidate compiled with plain `rustc` — no cargo crate, so #261's crate-edge DAG gate is untouched. It replaces the shebang stubs, which were Unix-only and had forced four compare controls to be declared not-applicable on Windows: that gap is why three PowerShell defects survived a 16/16 campaign. All seventeen shared controls now run on both platforms with zero N/A, and `tests/test_stage1_ps1.py` adds the PowerShell surface to the adversarial set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYLNQy6tLXqsV1CbuNqsSb
1 parent 2d6e43a commit 7ca3655

11 files changed

Lines changed: 1070 additions & 69 deletions

File tree

.github/workflows/ci.yml

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ jobs:
154154
- name: Build the fault-injection own-cli (forced failure modes)
155155
working-directory: rust
156156
run: cargo build -p own-cli --release --features fault-injection --target-dir target-fault
157+
# The controllable native candidate the compare controls need. Built
158+
# with plain `rustc`, deliberately NOT as a cargo workspace member, so it
159+
# cannot move the crate-edge DAG that #261's gate pins.
160+
- name: Build the Stage-1 stub candidate
161+
run: |
162+
ext=""
163+
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
164+
rustc -O tests/helpers/stage1_stub.rs -o "$RUNNER_TEMP/stage1-stub$ext"
157165
- name: Build the owen launcher
158166
run: dotnet build frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj -c Release --nologo
159167
# The vendored Python core is a PACK-time payload, so a plain build does
@@ -164,16 +172,30 @@ jobs:
164172
out=frontend/roslyn/OwnSharp.Cli/bin/Release/net8.0
165173
mkdir -p "$out/ownlang-core/ownlang"
166174
cp ownlang/*.py "$out/ownlang-core/ownlang/"
167-
- name: Stage-1 engine controls (all fifteen, no fail-fast)
175+
- name: Stage-1 engine controls (no fail-fast)
168176
env:
169177
OWEN_STAGE1_REQUIRE: "1"
170178
run: |
171179
ext=""
172180
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
173181
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext"
174182
export OWEN_STAGE1_RUST_FAULT="$PWD/rust/target-fault/release/own-cli$ext"
183+
export OWEN_STAGE1_STUB="$RUNNER_TEMP/stage1-stub$ext"
175184
export OWEN_STAGE1_LAUNCHER_DLL="$PWD/frontend/roslyn/OwnSharp.Cli/bin/Release/net8.0/ownsharp.dll"
176185
python tests/test_stage1_engine.py
186+
# The PowerShell surface's own controls. They run on BOTH legs — the
187+
# logic is platform-neutral and a Linux run catches regressions early —
188+
# but only the Windows leg is evidence for a PowerShell-targeted
189+
# mutation, which is what the stage1-ps1-mutations job below settles.
190+
- name: Stage-1 PowerShell controls
191+
env:
192+
OWEN_STAGE1_REQUIRE: "1"
193+
run: |
194+
ext=""
195+
if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi
196+
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext"
197+
export OWEN_STAGE1_STUB="$RUNNER_TEMP/stage1-stub$ext"
198+
python tests/test_stage1_ps1.py
177199
# The explicit Rust-selected run on this platform, through the shell
178200
# launcher, recorded as its own step so the evidence names the surface
179201
# and the platform rather than being inferred from a green job.
@@ -225,6 +247,75 @@ jobs:
225247
# require.
226248
exit 0
227249
250+
# P-022 step 8 (#262) Stage 1 — the WINDOWS-NATIVE mutation leg.
251+
#
252+
# A mutation whose target is scripts/own-check.ps1 is only `caught` when a
253+
# Windows PowerShell catcher observes the mutant and fails. Running those
254+
# mutants on Linux would execute the mutated PowerShell under a different
255+
# runtime, and the Windows-specific halves — the spawn seam above all —
256+
# cannot be settled there at all: a mutant that runs where its control is
257+
# weakest is decorative, and proves nothing about the surface it edits.
258+
#
259+
# So this campaign runs here, on Windows, and this job is the gate. It fails
260+
# unless every mutation is caught with its expected catcher and the
261+
# honesty control survives the unmutated tree.
262+
stage1-ps1-mutations:
263+
name: own-check.ps1 mutation campaign (Windows-native)
264+
runs-on: windows-latest
265+
defaults:
266+
run:
267+
shell: bash
268+
steps:
269+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
270+
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
271+
with:
272+
dotnet-version: "8.0.x"
273+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
274+
with:
275+
python-version: "3.13"
276+
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
277+
with:
278+
toolchain: stable
279+
- name: Build the production own-cli candidate
280+
working-directory: rust
281+
run: cargo build -p own-cli --release
282+
- name: Build the Stage-1 stub candidate
283+
run: rustc -O tests/helpers/stage1_stub.rs -o "$RUNNER_TEMP/stage1-stub.exe"
284+
- name: Run the PowerShell mutation campaign
285+
env:
286+
OWEN_STAGE1_REQUIRE: "1"
287+
run: |
288+
export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli.exe"
289+
export OWEN_STAGE1_STUB="$RUNNER_TEMP/stage1-stub.exe"
290+
python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage1-ps1.json --run
291+
# The recorded run, printed in full so its provenance and per-mutation
292+
# catchers can be read off this job rather than taken on trust — and so
293+
# the committed record can be reproduced from a named CI run, the way
294+
# #260's sweep record is.
295+
- name: Print the recorded result
296+
if: always()
297+
run: cat docs/evidence/p022-stage1-ps1.result.json
298+
- name: Assert every mutation was caught by a Windows catcher
299+
run: |
300+
python - <<'PY'
301+
import json, sys
302+
d = json.load(open("docs/evidence/p022-stage1-ps1.result.json", encoding="utf-8"))
303+
defn = json.load(open("docs/evidence/p022-stage1-ps1.json", encoding="utf-8"))
304+
exp = {m["id"]: set(m["expected_catchers"]) for m in defn["mutations"]}
305+
problems = []
306+
if d["control"]["outcome"] != "survived":
307+
problems.append("the honesty control did not survive the unmutated tree")
308+
for m in d["mutations"]:
309+
if m["outcome"] != "caught":
310+
problems.append(f"{m['id']}: {m['outcome']}")
311+
elif not exp[m["id"]] <= set(m["catchers"]):
312+
problems.append(f"{m['id']}: expected catchers missed ({m['catchers']})")
313+
elif not any(c.startswith("ps1::") for c in m["catchers"]):
314+
problems.append(f"{m['id']}: no PowerShell catcher observed it")
315+
print("\n".join(problems) if problems else "every ps1 mutation caught by a Windows catcher")
316+
sys.exit(1 if problems else 0)
317+
PY
318+
228319
# P-022 step 7a (#260) — COMPARE MODE over the committed corpus: the FAST half
229320
# of #260's test matrix, and one leg of it. The five pinned OSS repositories,
230321
# the large-solution controls and the examples tree are the scheduled/manual

docs/evidence/p022-stage1-1.json

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@
126126
"rule": "divergence-is-5",
127127
"description": "a divergence exposes the reference's result — 'Python is still the reference, so trust it' read as a licence to answer while the two engines disagree",
128128
"target": "frontend/roslyn/OwnSharp.Cli/CompareMode.cs",
129-
"pattern": " return Fail\\(args, rust, py, rs,\\n \\$\\\"engine divergence",
130-
"replacement": " await ReplayAsync(py).ConfigureAwait(false);\n return failOnFinding ? py.Rc : (py.Rc >= 2 ? py.Rc : 0);\n#pragma warning disable CS0162\n return Fail(args, rust, py, rs,\n $\"engine divergence",
129+
"pattern": " return Fail\\(args, rust, py, rs, Divergence,\\n \\$\\\"engine divergence",
130+
"replacement": " await ReplayAsync(py).ConfigureAwait(false);\n return failOnFinding ? py.Rc : (py.Rc >= 2 ? py.Rc : 0);\n#pragma warning disable CS0162\n return Fail(args, rust, py, rs, Divergence,\n $\"engine divergence",
131131
"expected_catchers": [
132132
"stage1::divergence-is-5",
133133
"stage1::compare-no-substitution"
@@ -198,6 +198,39 @@
198198
"expected_catchers": [
199199
"stage1::compare-zero-document"
200200
]
201+
},
202+
{
203+
"id": "M17",
204+
"rule": "locator-must-be-absolute",
205+
"description": "the launcher accepts a relative OWEN_RUST_CORE — `GetFullPath` reads as 'it resolves the path for me', which it does: against whatever directory Owen happened to run in",
206+
"target": "frontend/roslyn/OwnSharp.Cli/RustCoreLocator.cs",
207+
"pattern": " if \\(!Path\\.IsPathFullyQualified\\(raw\\)\\)",
208+
"replacement": " if (false)",
209+
"expected_catchers": [
210+
"stage1::absolute-locator-only"
211+
]
212+
},
213+
{
214+
"id": "M18",
215+
"rule": "shell-locator-must-be-absolute",
216+
"description": "the shell accepts a relative OWEN_RUST_CORE — the -f/-x tests look like they answer 'is this a usable binary', and they do, for whatever the current directory made of the path",
217+
"target": "scripts/own-check.sh",
218+
"pattern": " elif \\[\\[ \\\"\\$is_absolute\\\" -eq 0 \\]\\]; then",
219+
"replacement": " elif false; then",
220+
"expected_catchers": [
221+
"stage1::absolute-locator-only"
222+
]
223+
},
224+
{
225+
"id": "M19",
226+
"rule": "compare-verdict-is-stated",
227+
"description": "the compare verdict is inferred from the Rust-child field again — `child_exit_code is null` reads as 'no engine crashed', but it is only ever about the RUST child, so a Python-only failure is stamped 'divergence'",
228+
"target": "frontend/roslyn/OwnSharp.Cli/CompareMode.cs",
229+
"pattern": " verdict: verdict, diagnostic: diagnostic, childExitCode: childExitCode\\);",
230+
"replacement": " verdict: childExitCode is null && py is not null && rs is not null\n ? \"divergence\"\n : \"execution-failure\",\n diagnostic: diagnostic, childExitCode: childExitCode);",
231+
"expected_catchers": [
232+
"stage1::compare-failure-classified"
233+
]
201234
}
202235
]
203236
}

docs/evidence/p022-stage1-ps1.json

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"schema": 1,
3+
"comment": "GENERATED-BY-HAND definition; the RESULT beside it is recorded by scripts/mutate_campaign.py --run on a WINDOWS runner and the counts are derived from it, never typed.",
4+
"campaign": "p022-stage1-ps1",
5+
"description": "#262 Stage 1 — the PowerShell launcher surface. `scripts/own-check.ps1` was driven by a CI smoke step but was absent from the adversarial control set, which is why three of its engine-contract defects survived a 16/16 campaign on the other surfaces: a campaign can only prove what some control observes. Every mutation here targets own-check.ps1 itself, and every one is a plausible MISREADING rather than a syntactic accident — Test-Path answering a question D3 did not ask, a failed spawn read as the engine blowing up, `Get-Content | Write-Output` read as an echo, cleanup read as tidiness.",
6+
"layers": [
7+
{
8+
"id": "ps1",
9+
"cwd": ".",
10+
"parser": "python-fail",
11+
"command": [
12+
"python",
13+
"tests/test_stage1_ps1.py"
14+
]
15+
}
16+
],
17+
"layers_comment": "WINDOWS-NATIVE BY CONTRACT. A mutation whose target is scripts/own-check.ps1 is only `caught` when a Windows PowerShell catcher observes the mutant and fails; this campaign therefore runs on a windows-latest runner (.github/workflows/ci.yml, job `stage1-ps1-mutations`). Running it on Linux would execute the mutated PowerShell under a different runtime and could not settle the Windows-specific halves — the spawn seam above all — so a Linux result is not evidence for these mutants and is never recorded as one.",
18+
"control": {
19+
"id": "M00",
20+
"description": "harness-honesty control: no mutation at all, which must report zero failing layers"
21+
},
22+
"mutations": [
23+
{
24+
"id": "P01",
25+
"rule": "ps1-locator-must-be-absolute",
26+
"description": "own-check.ps1 accepts a relative OWEN_RUST_CORE — Test-Path says the file is there, which is true and not the question D3 asks",
27+
"target": "scripts/own-check.ps1",
28+
"pattern": " elseif \\(-not \\[System\\.IO\\.Path\\]::IsPathFullyQualified\\(\\$rustCore\\)\\) \\{",
29+
"replacement": " elseif ($false) {",
30+
"expected_catchers": [
31+
"ps1::ps1-absolute-locator"
32+
]
33+
},
34+
{
35+
"id": "P02",
36+
"rule": "ps1-not-started-is-configuration",
37+
"description": "own-check.ps1 reports a candidate that never started as an internal failure — 'the engine blew up' read as Owen's bug rather than the caller's configuration, which is the exact side of D3.1's seam the old comment got backwards",
38+
"target": "scripts/own-check.ps1",
39+
"pattern": " exit 2\n \\}\n # 0/1/2 are verdicts and pass through",
40+
"replacement": " exit 5\n }\n # 0/1/2 are verdicts and pass through",
41+
"expected_catchers": [
42+
"ps1::ps1-not-started-is-2"
43+
]
44+
},
45+
{
46+
"id": "P03",
47+
"rule": "ps1-agreement-replays-raw-bytes",
48+
"description": "own-check.ps1 replays agreement through the object pipeline again, stdout only — `Get-Content | Write-Output` looks like an echo and is a decode-and-re-encode that also drops stderr",
49+
"target": "scripts/own-check.ps1",
50+
"pattern": " \\$outBytes = \\[System\\.IO\\.File\\]::ReadAllBytes\\(\\(Join-Path \\$cmpDir \"python\\.out\"\\)\\)\n \\$errBytes = \\[System\\.IO\\.File\\]::ReadAllBytes\\(\\(Join-Path \\$cmpDir \"python\\.err\"\\)\\)",
51+
"replacement": " Get-Content -LiteralPath (Join-Path $cmpDir \"python.out\") -Raw -ErrorAction SilentlyContinue | Write-Output\n $outBytes = @(); $errBytes = @()",
52+
"expected_catchers": [
53+
"ps1::ps1-agreement-replays"
54+
]
55+
},
56+
{
57+
"id": "P04",
58+
"rule": "ps1-failure-evidence-survives",
59+
"description": "own-check.ps1 deletes the reproduction directory it just named — cleanup reads as tidiness, and the message that pointed at it is left describing something that no longer exists",
60+
"target": "scripts/own-check.ps1",
61+
"pattern": " # reader at a path and then deleting it on the way out is worse\n # than not naming one at all\\.\n \\$keep = \\$true",
62+
"replacement": " # reader at a path and then deleting it on the way out is worse\n # than not naming one at all.\n $keep = $false",
63+
"expected_catchers": [
64+
"ps1::ps1-failure-evidence"
65+
]
66+
}
67+
]
68+
}

0 commit comments

Comments
 (0)