diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c1d721a..0572ebd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,34 @@ jobs: # logic is platform-neutral and a Linux run catches regressions early — # but only the Windows leg is evidence for a PowerShell-targeted # mutation, which is what the stage1-windows-mutations job below settles. + # P-022 Stage 3 (#262): the cutover's own controls. They live here because + # this is the job that already builds the candidate, the fault-injection + # binary and the launcher, and they run on BOTH legs of its matrix -- the + # rollback and the cancellation disposition are platform claims, and the + # Windows one cannot be inferred from the Linux one. + - name: Stage-3 rollback controls (the four states, kept distinct) + env: + OWEN_STAGE3_REQUIRE: "1" + run: | + ext="" + if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi + export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext" + export OWEN_STAGE1_LAUNCHER_DLL="$PWD/frontend/roslyn/OwnSharp.Cli/bin/Release/net8.0/ownsharp.dll" + python tests/test_stage3_rollback.py + # Cancellation is MEASURED per platform, not contracted as a number: #262 + # forbids inventing a universal 130, and the two platforms do not even + # offer the same mechanism. The control asserts only what the evidence + # supports and PRINTS the disposition it found, on a line prefixed + # STAGE3-CANCELLATION-RECORD, so the Windows measurement is read off this + # job rather than predicted anywhere. + - name: Stage-3 cancellation, measured on this platform + env: + OWEN_STAGE3_REQUIRE: "1" + run: | + ext="" + if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi + export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext" + python tests/test_stage3_cancellation.py - name: Stage-1 PowerShell controls env: OWEN_STAGE1_REQUIRE: "1" @@ -2068,14 +2096,22 @@ jobs: # Action plumbing: own-check --config own.toml parses [weak-subscription] and # forwards it to the extractor, so the wrapper is silent end-to-end. printf '[weak-subscription]\nsubscribe = ["WeakEvents.AddPropertyChanged"]\n' > "$RUNNER_TEMP/own.toml" - cout=$(scripts/own-check.sh --config "$RUNNER_TEMP/own.toml" "$sample" || true) + # --engine python is EXPLICIT here, and became explicit at #262 Stage 3. + # This is a class-B call site in the Stage-2 census: the reference + # implementation is the instrument, and the step above asserted the + # same sample through `python -m ownlang ownir` directly. When the + # public default moved to Rust, a bare call here would have quietly + # changed which engine this comparison is against -- the measurement + # would still have passed, and would no longer have been the + # measurement it says it is. + cout=$(scripts/own-check.sh --engine python --config "$RUNNER_TEMP/own.toml" "$sample" || true) ! echo "$cout" | grep -q "WeaklySubscribed" \ || { echo "FAIL: own-check --config did not silence the declared wrapper"; exit 1; } echo "$cout" | grep -q "OrdinaryPlusEquals" \ || { echo "FAIL: own-check --config lost the ordinary += leak"; exit 1; } # A malformed config is a hard error (non-zero), never a silent skip. printf '[weak-subscription]\nsubscribe = ["bad_no_dot"]\n' > "$RUNNER_TEMP/bad.toml" - if scripts/own-check.sh --config "$RUNNER_TEMP/bad.toml" "$sample" >/dev/null 2>&1; then + if scripts/own-check.sh --engine python --config "$RUNNER_TEMP/bad.toml" "$sample" >/dev/null 2>&1; then echo "FAIL: malformed --config was silently accepted"; exit 1 fi echo "OK: declared weak-subscribe wrapper = accepted release; += unaffected; own.toml plumbed; malformed config is a hard error" @@ -2400,6 +2436,28 @@ jobs: - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: "8.0.x" + # #262 Stage 3: this is a PUBLIC-CONTRACT VERIFIER (Stage-2 census class A), + # so it invokes the surface BARE and gets whatever the public default is. + # That default is now Rust, so the job supplies a candidate the way a real + # install would. It builds the production `own-cli` rather than probing + # rust/target for one: probing is the discovery D3 forbids, and is how a + # stale binary stands in for the one under test. + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + - name: Build the production own-cli and select it (the public default is Rust) + shell: bash + run: | + set -euo pipefail + (cd rust && cargo build -p own-cli --release) + ext="" + if [ "$RUNNER_OS" = "Windows" ]; then ext=".exe"; fi + # Named owen_core rather than core: the Stage-2 campaign anchors a + # mutant on the dogfood job's own `core="..."` line, and a second + # identical line in this file made that anchor ambiguous. + owen_core="$PWD/rust/target/release/own-cli$ext" + "$owen_core" --version + echo "OWEN_RUST_CORE=$owen_core" >> "$GITHUB_ENV" - name: GitHub-annotation format over the sample tree (directory walk) run: | # stdout (captured) carries only the annotations; the extractor's build @@ -2608,6 +2666,31 @@ jobs: - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: "8.0.x" + # #262 Stage 3: this is a PUBLIC-CONTRACT VERIFIER (Stage-2 census class A), + # so it invokes the surface BARE and gets whatever the public default is. + # That default is now Rust, so the job supplies a candidate the way a real + # install would. It builds the production `own-cli` rather than probing + # rust/target for one: probing is the discovery D3 forbids, and is how a + # stale binary stands in for the one under test. + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + # The locator is set from POWERSHELL's $PWD, not bash's, and that is the + # whole point of not reusing the bash step the sibling jobs use. In + # git-bash on a Windows runner $PWD is the MSYS form (/d/a/Own.NET/...), + # and own-check.ps1 correctly refuses it: D3 ratified an ABSOLUTE path, + # and /d/... is not one to PowerShell. Writing that form into $GITHUB_ENV + # exported it to every later step and turned a correct configuration into + # a usage error -- which is exactly what happened on the first run of this + # job after the cutover. + - name: Build the production own-cli and select it (the public default is Rust) + run: | + Push-Location rust + cargo build -p own-cli --release + Pop-Location + $core = Join-Path $PWD "rust\target\release\own-cli.exe" + & $core --version + "OWEN_RUST_CORE=$core" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - name: A clean tree to check against run: | $clean = Join-Path $env:RUNNER_TEMP "ps1-clean" @@ -2869,7 +2952,18 @@ jobs: $rc = $LASTEXITCODE $out | Write-Host if ($rc -ne 1) { throw "expected exit 1 (findings), got $rc" } - if ($out -notmatch 'OWN001') { throw "the Rust core found no OWN001" } + # `-not ($out -match ...)`, NOT `$out -notmatch ...`. On an ARRAY + # PowerShell's -notmatch is a FILTER, not a boolean: it returns the + # elements that do not match, and a non-empty result is truthy. So + # over a 166-line capture where 137 lines carry OWN001 it returns the + # other 29 and the assertion fires anyway. + # + # The mirror image is how it passed before: while the Rust branch + # wrote to an inherited console handle, $out was $null, -notmatch + # filtered an empty collection to an empty collection, and the step + # went green having captured and verified NOTHING. Both directions + # are the same bug, and this is the idiom the rest of this file uses. + if (-not ($out -match 'OWN001')) { throw "the Rust core found no OWN001" } Write-Host "OK: Rust-default dogfood on windows (own-check.ps1)" exit 0 # No fallback, measured rather than asserted: with the candidate broken, @@ -3202,8 +3296,26 @@ jobs: # published there yet, see P-013's Non-goals) -- pack from the source # this job already checked out. Deliberately OUTSIDE the timed window # below: it is not part of the "install -> check" claim being proven. + # #262 Stage 3, D6: Rust is the public default, so the PACKAGE has to carry + # the binary that default resolves to. Built natively on each leg of this + # matrix, which is what makes this job the Windows packed-path evidence as + # well as the Linux one -- a cross-compiled candidate would be a binary no + # Windows machine had executed. + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + - name: Build and stage this platform's own-cli for packing + run: | + set -euo pipefail + (cd rust && cargo build -p own-cli --release) + if [ "$RUNNER_OS" = "Windows" ]; then key=win-x64; bin=own-cli.exe; else key=linux-x64; bin=own-cli; fi + mkdir -p "$RUNNER_TEMP/rust-stage/$key" + cp "rust/target/release/$bin" "$RUNNER_TEMP/rust-stage/$key/$bin" + "rust/target/release/$bin" --version - name: Pack Owen.Cli (pulls in the extractor via ProjectReference) - run: dotnet pack frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj -c Release -o "$RUNNER_TEMP/owen-nupkg" + run: | + dotnet pack frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj -c Release \ + -o "$RUNNER_TEMP/owen-nupkg" -p:OwenRustCoreDir="$RUNNER_TEMP/rust-stage" # --add-source alone is not enough (Codex review, PR #244): `dotnet tool # install` queries every configured source (nuget.org included) IN # PARALLEL and takes whichever answers first — once a same-numbered @@ -3374,7 +3486,15 @@ jobs: EOF chmod +x "$RUNNER_TEMP/crashing-python" set +e - out=$(OWEN_PYTHON="$RUNNER_TEMP/crashing-python" owen check "$RUNNER_TEMP/owen-sample" 2>&1) + # --engine python is EXPLICIT, and became explicit at #262 Stage 3. + # This step injects a crashing PYTHON, so it has to be running the + # Python engine to mean anything. Once the public default became Rust, + # a bare `owen check` here would never consult OWEN_PYTHON at all: the + # crash would not happen, the scan would succeed, and the step would + # go green while proving nothing. The Rust core's own crash path is + # measured separately, through #261's fault-injection feature, by the + # Stage-1 engine controls. + out=$(OWEN_PYTHON="$RUNNER_TEMP/crashing-python" owen check --engine python "$RUNNER_TEMP/owen-sample" 2>&1) rc=$? set -e echo "$out" @@ -3389,7 +3509,10 @@ jobs: if: runner.os == 'Linux' run: | set +e - out=$(OWEN_PYTHON="$RUNNER_TEMP/crashing-python" owen check --debug "$RUNNER_TEMP/owen-sample" 2>&1) + # --engine python is EXPLICIT (#262 Stage 3): this step is about the PYTHON + # resolution path, and a bare invocation now runs Rust and never consults + # OWEN_PYTHON at all -- it would go green while proving nothing. + out=$(OWEN_PYTHON="$RUNNER_TEMP/crashing-python" owen check --engine python --debug "$RUNNER_TEMP/owen-sample" 2>&1) rc=$? set -e echo "$out" @@ -3733,10 +3856,32 @@ jobs: elapsed=$(( $(date +%s) - SMOKE_START )) echo "install -> check -> findings: ${elapsed}s" [ "$elapsed" -lt 240 ] || { echo "FAIL: took ${elapsed}s (ceiling 240s) — see alpha-readiness.md gate A"; exit 1; } + # #262 Stage 3, the cutover assertion on the INSTALLED surface, on both + # platforms. A bare `owen check` must produce a verdict while the + # interpreter is deliberately unusable: only a default that resolves the + # PACKAGED Rust core can. A Python default -- or a default that drifted to + # compare -- exits 3 here, which is what the step below still asserts for + # the explicitly-selected Python engine. + - name: "Stage 3: the DEFAULT engine needs no Python (packaged Rust core, D6)" + run: | + set +e + out=$(OWEN_PYTHON=/definitely/does/not/exist/python3 owen check "$RUNNER_TEMP/owen-sample" --fail-on-finding 2>&1) + rc=$? + set -e + echo "$out" + [ "$rc" -eq 1 ] || { echo "FAIL: bare owen check with an unusable OWEN_PYTHON exited $rc, expected 1 (findings) — the public default still needs Python, so the Stage-3 cutover did not reach this surface"; exit 1; } + echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001 from the default (Rust) engine"; exit 1; } + case "$out" in + *OWEN_RUST_CORE*) echo "FAIL: the default consulted the development locator instead of the packaged core"; exit 1 ;; + esac + echo "OK: the installed package's DEFAULT engine is Rust and resolved its own packaged binary" - name: No Python found via OWEN_PYTHON -> a fast, actionable failure (never an auto-download) run: | set +e - out=$(OWEN_PYTHON=/definitely/does/not/exist/python3 owen check "$RUNNER_TEMP/owen-sample" 2>&1) + # --engine python is EXPLICIT (#262 Stage 3): this step is about the PYTHON + # resolution path, and a bare invocation now runs Rust and never consults + # OWEN_PYTHON at all -- it would go green while proving nothing. + out=$(OWEN_PYTHON=/definitely/does/not/exist/python3 owen check --engine python "$RUNNER_TEMP/owen-sample" 2>&1) rc=$? set -e echo "$out" @@ -3748,7 +3893,10 @@ jobs: own_python="$(command -v python3 || command -v python)" [ -n "$own_python" ] || { echo "FAIL: could not find a python3/python on PATH to test the fallback with"; exit 1; } set +e - out=$(OWN_PYTHON="$own_python" owen check "$RUNNER_TEMP/owen-sample" --fail-on-finding 2>&1) + # --engine python is EXPLICIT (#262 Stage 3): this step is about the PYTHON + # resolution path, and a bare invocation now runs Rust and never consults + # OWEN_PYTHON at all -- it would go green while proving nothing. + out=$(OWN_PYTHON="$own_python" owen check --engine python "$RUNNER_TEMP/owen-sample" --fail-on-finding 2>&1) rc=$? set -e echo "$out" @@ -3764,7 +3912,12 @@ jobs: rm -rf "$HOME/.owen" "$HOME/.ownsharp" mkdir -p "$HOME/.ownsharp/core/0.1.0/ownlang" cp ownlang/*.py "$HOME/.ownsharp/core/0.1.0/ownlang/" - driver=$(owen check "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])") + # --engine python is EXPLICIT (#262 Stage 3): this step is about the + # VENDORED PYTHON core's cache, and only the Python engine unpacks it. + # Since the cutover a bare invocation runs Rust and never touches + # ~/.owen/core, so these assertions would be about a directory nothing + # wrote -- this one failed in CI exactly that way. + driver=$(owen check --engine python "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])") [ "$driver" = "Owen" ] || { echo "FAIL: driver name '$driver' via the legacy cache"; exit 1; } if [ -d "$HOME/.owen" ]; then echo "FAIL: ~/.owen was created despite a legitimate matching legacy cache (fallback not reused in place)"; exit 1 @@ -3779,7 +3932,12 @@ jobs: mkdir -p "$HOME/.ownsharp/core/0.1.0/ownlang" cp ownlang/*.py "$HOME/.ownsharp/core/0.1.0/ownlang/" echo "# stale leftover module" > "$HOME/.ownsharp/core/0.1.0/ownlang/removed_module.py" - driver=$(owen check "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])") + # --engine python is EXPLICIT (#262 Stage 3): this step is about the + # VENDORED PYTHON core's cache, and only the Python engine unpacks it. + # Since the cutover a bare invocation runs Rust and never touches + # ~/.owen/core, so these assertions would be about a directory nothing + # wrote -- this one failed in CI exactly that way. + driver=$(owen check --engine python "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])") [ "$driver" = "Owen" ] || { echo "FAIL: driver name '$driver'"; exit 1; } [ -d "$HOME/.owen" ] || { echo "FAIL: expected a fresh ~/.owen unpack (extra-file legacy cache should have been rejected)"; exit 1; } if find "$HOME/.owen" -name "removed_module.py" | grep -q .; then @@ -3794,7 +3952,12 @@ jobs: mkdir -p "$HOME/.ownsharp/core/0.1.0/ownlang" cp ownlang/*.py "$HOME/.ownsharp/core/0.1.0/ownlang/" echo "# tampered" >> "$HOME/.ownsharp/core/0.1.0/ownlang/ownir.py" - driver=$(owen check "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])") + # --engine python is EXPLICIT (#262 Stage 3): this step is about the + # VENDORED PYTHON core's cache, and only the Python engine unpacks it. + # Since the cutover a bare invocation runs Rust and never touches + # ~/.owen/core, so these assertions would be about a directory nothing + # wrote -- this one failed in CI exactly that way. + driver=$(owen check --engine python "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])") [ "$driver" = "Owen" ] || { echo "FAIL: driver name '$driver'"; exit 1; } [ -d "$HOME/.owen" ] || { echo "FAIL: expected a fresh ~/.owen unpack (modified-content legacy cache should have been rejected)"; exit 1; } echo "OK: modified-content legacy cache correctly rejected" @@ -3929,12 +4092,22 @@ jobs: # stale file is gone from whatever cache directory actually got used. run: | rm -rf "$HOME/.owen" "$HOME/.ownsharp" - owen check "$RUNNER_TEMP/owen-sample" --fail-on-finding > /dev/null 2>&1 || true + # --engine python is EXPLICIT (#262 Stage 3): this step is about the + # VENDORED PYTHON core's cache, and only the Python engine unpacks it. + # Since the cutover a bare invocation runs Rust and never touches + # ~/.owen/core, so these assertions would be about a directory nothing + # wrote -- this one failed in CI exactly that way. + owen check --engine python "$RUNNER_TEMP/owen-sample" --fail-on-finding > /dev/null 2>&1 || true cache_dir=$(find "$HOME/.owen/core" -mindepth 2 -maxdepth 2 -type d) [ -n "$cache_dir" ] || { echo "FAIL: first run did not create a current-cache directory"; exit 1; } echo "# tampered" >> "$cache_dir/ownlang/ownir.py" echo "# stale leftover module" > "$cache_dir/ownlang/stale_module.py" - driver=$(owen check "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])") + # --engine python is EXPLICIT (#262 Stage 3): this step is about the + # VENDORED PYTHON core's cache, and only the Python engine unpacks it. + # Since the cutover a bare invocation runs Rust and never touches + # ~/.owen/core, so these assertions would be about a directory nothing + # wrote -- this one failed in CI exactly that way. + driver=$(owen check --engine python "$RUNNER_TEMP/owen-sample" --format sarif | python -c "import json,sys; print(json.load(sys.stdin)['runs'][0]['tool']['driver']['name'])") [ "$driver" = "Owen" ] || { echo "FAIL: driver name '$driver' after rebuild"; exit 1; } if find "$HOME/.owen" -name "stale_module.py" | grep -q .; then echo "FAIL: the stale extra file survived under a used cache directory"; exit 1 diff --git a/.github/workflows/mine-on-push.yml b/.github/workflows/mine-on-push.yml index 8cb68420..6db9c47e 100644 --- a/.github/workflows/mine-on-push.yml +++ b/.github/workflows/mine-on-push.yml @@ -68,6 +68,17 @@ jobs: fi { echo "target=$target"; echo "ref=$ref"; echo "paths=$paths"; } >> "$GITHUB_OUTPUT" echo "mine-on-push: $target (ref='${ref:-HEAD}' paths='${paths:-*}')" + # #262 Stage 3: scripts/mine.sh drives the BARE launcher surface, whose + # default engine is now the Rust core, so this job supplies a candidate. + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + - name: Build the production own-cli and select it + run: | + set -euo pipefail + (cd rust && cargo build -p own-cli --release) + "$PWD/rust/target/release/own-cli" --version + echo "OWEN_RUST_CORE=$PWD/rust/target/release/own-cli" >> "$GITHUB_ENV" - name: Mine the target env: REPO: ${{ steps.target.outputs.target }} diff --git a/.github/workflows/mine.yml b/.github/workflows/mine.yml index 22d8c6bf..f2c3abc9 100644 --- a/.github/workflows/mine.yml +++ b/.github/workflows/mine.yml @@ -37,6 +37,17 @@ jobs: - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 with: dotnet-version: "8.0.x" + # #262 Stage 3: scripts/mine.sh drives the BARE launcher surface, whose + # default engine is now the Rust core, so this job supplies a candidate. + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + - name: Build the production own-cli and select it + run: | + set -euo pipefail + (cd rust && cargo build -p own-cli --release) + "$PWD/rust/target/release/own-cli" --version + echo "OWEN_RUST_CORE=$PWD/rust/target/release/own-cli" >> "$GITHUB_ENV" - name: Mine the target env: REPO: ${{ inputs.repo }} diff --git a/.github/workflows/oracle.yml b/.github/workflows/oracle.yml index 19251acd..0dbf0d78 100644 --- a/.github/workflows/oracle.yml +++ b/.github/workflows/oracle.yml @@ -174,7 +174,13 @@ jobs: run: | scan="target"; [[ -n "$PATHS" ]] && scan="target/$PATHS" set +e - scripts/own-check.sh --format sarif --severity warning -- "$scan" > own.txt 2> own-extract.log + # --engine python is EXPLICIT (#262 Stage 3). This is a class-B call + # site in the Stage-2 census: a cross-tool comparison against Infer# + # and CodeQL, where the reference implementation is the INSTRUMENT. + # When the public default moved to Rust a bare call here would have + # silently changed which engine the comparison is of, so the engine + # this oracle has always measured is now named rather than inherited. + scripts/own-check.sh --engine python --format sarif --severity warning -- "$scan" > own.txt 2> own-extract.log echo "own-check rc=$? ; own.txt is a SARIF log ($(wc -c < own.txt) bytes)" # CodeQL — database from source (no build). The dispose/leak queries diff --git a/.github/workflows/owen-cli-release.yml b/.github/workflows/owen-cli-release.yml index 4638b987..041e4a6b 100644 --- a/.github/workflows/owen-cli-release.yml +++ b/.github/workflows/owen-cli-release.yml @@ -51,9 +51,68 @@ env: CLI_PROJECT: frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj jobs: + # P-022 Stage 3 (#262), D6: the Rust core ships INSIDE the package, so it has + # to be built before the package is made, and built for every platform the + # package supports. + # + # Natively, on each platform's own runner, rather than cross-compiled from + # one. Cross-compiling would be cheaper and would prove less: the binary a + # Windows user runs would then be one no Windows machine had ever executed, + # and #262's whole distribution section exists because "it built" and "it + # works where it lands" are different claims. The platform key each leg + # stages under is the one RustCoreLocator.PlatformKey() computes at runtime, + # so the two sides cannot drift into disagreeing about where the binary is. + build-rust-core: + name: build own-cli (${{ matrix.platform_key }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform_key: linux-x64 + binary: own-cli + - os: windows-latest + platform_key: win-x64 + binary: own-cli.exe + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + - name: cargo build -p own-cli --release + working-directory: rust + run: cargo build -p own-cli --release + # The binary is exercised HERE, on the machine that built it, before it is + # allowed anywhere near a package. A candidate that cannot answer + # --version is not one to ship, and finding that out at pack time beats + # finding it out from a user. + - name: The built candidate runs on its own platform + run: | + set -euo pipefail + "rust/target/release/${{ matrix.binary }}" --version + - name: Stage it under the platform key the locator computes + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/rust-stage/${{ matrix.platform_key }}" + cp "rust/target/release/${{ matrix.binary }}" \ + "$RUNNER_TEMP/rust-stage/${{ matrix.platform_key }}/${{ matrix.binary }}" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: own-cli-${{ matrix.platform_key }} + path: ${{ runner.temp }}/rust-stage/${{ matrix.platform_key }}/${{ matrix.binary }} + retention-days: 14 + if-no-files-found: error + build-test-pack: name: build + test + pack runs-on: ubuntu-latest + needs: build-rust-core outputs: version: ${{ steps.version.outputs.version }} steps: @@ -113,8 +172,34 @@ jobs: fi echo "OK: tag matches csproj Version ($csproj_version)" + # D6: collect every platform's candidate, then pack with them. + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: own-cli-* + path: ${{ runner.temp }}/rust-stage + - name: Stage the Rust cores for packing + run: | + set -euo pipefail + # download-artifact lands each artifact in a directory named after + # itself (own-cli-linux-x64/own-cli), and the locator looks under the + # PLATFORM KEY (linux-x64/own-cli). Rename rather than reshape the + # artifact names: the platform key is the contract, and the artifact + # name is just how CI moves bytes between jobs. + cd "$RUNNER_TEMP/rust-stage" + for d in own-cli-*; do + mv "$d" "${d#own-cli-}" + done + chmod +x linux-x64/own-cli + ls -laR . + # Both platforms, named. A pack that quietly lost a leg would produce + # a package whose default engine is missing on exactly one OS, and + # the smoke test on the OTHER leg would still be green. + test -f linux-x64/own-cli || { echo "FAIL: no linux-x64 own-cli staged"; exit 1; } + test -f win-x64/own-cli.exe || { echo "FAIL: no win-x64 own-cli.exe staged"; exit 1; } - name: dotnet pack - run: dotnet pack "$CLI_PROJECT" -c Release -o "$RUNNER_TEMP/nupkg" + run: | + dotnet pack "$CLI_PROJECT" -c Release -o "$RUNNER_TEMP/nupkg" \ + -p:OwenRustCoreDir="$RUNNER_TEMP/rust-stage" - name: Inspect package contents (bundled runtime/core assets present) run: | set -euo pipefail @@ -133,7 +218,24 @@ jobs: core_py_count=$(find "$RUNNER_TEMP/nupkg-inspect/tools/net8.0/any/ownlang-core/ownlang" -name "*.py" 2>/dev/null | wc -l) [ "$core_py_count" -gt 0 ] \ || { echo "FAIL: vendored ownlang core .py files missing from the package"; exit 1; } - echo "OK: package contains the CLI, the bundled extractor, and $core_py_count vendored core .py files" + # D6 (#262 Stage 3): the engine the package makes DEFAULT must be in + # the package, for every supported platform. Asserted per platform + # and by size: a zero-byte or truncated payload would satisfy a bare + # existence test and fail at the only moment that matters. + rc_root="$RUNNER_TEMP/nupkg-inspect/tools/net8.0/any/rust-core" + for leg in "linux-x64/own-cli" "win-x64/own-cli.exe"; do + test -s "$rc_root/$leg" \ + || { echo "FAIL: packed Rust core missing or empty: rust-core/$leg"; exit 1; } + echo " rust-core/$leg: $(wc -c < "$rc_root/$leg") bytes" + done + # And nothing ELSE is under rust-core/: a stray file there is either a + # platform nobody supports or a payload packed twice, and both have + # happened while this was being built. + extra=$(find "$rc_root" -type f | wc -l) + [ "$extra" -eq 2 ] \ + || { echo "FAIL: rust-core/ holds $extra files, expected exactly 2"; \ + find "$rc_root" -type f; exit 1; } + echo "OK: package contains the CLI, the bundled extractor, $core_py_count vendored core .py files, and both platforms' Rust core" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: owen-cli-nupkg @@ -247,10 +349,33 @@ jobs: set -e echo "$out" [ "$rc" -eq 0 ] || { echo "FAIL: expected exit 0 on clean code, got $rc"; exit 1; } + # #262 Stage 3: the packed artifact's DEFAULT engine is Rust and carries + # its own binary (D6), so a bare check must work with no usable Python. + - name: "Stage 3: the packed artifact's default engine needs no Python" + run: | + set +e + out=$(OWEN_PYTHON=/definitely/does/not/exist/python3 owen check "$RUNNER_TEMP/sample" --fail-on-finding 2>&1) + rc=$? + set -e + echo "$out" + [ "$rc" -eq 1 ] || { echo "FAIL: bare owen check with an unusable OWEN_PYTHON exited $rc, expected 1 (findings) — the packed default still needs Python"; exit 1; } + echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001 from the default (Rust) engine"; exit 1; } + - name: "Stage 3: --engine python is the tested ROLLBACK, on the same install" + run: | + set +e + out=$(owen check --engine python "$RUNNER_TEMP/sample" --fail-on-finding 2>&1) + rc=$? + set -e + echo "$out" + [ "$rc" -eq 1 ] || { echo "FAIL: the documented rollback exited $rc, expected 1 (findings)"; exit 1; } + echo "$out" | grep -q "OWN001" || { echo "FAIL: the rollback engine produced no finding"; exit 1; } + echo "OK: Python remains selectable and working on the shipped package" - name: No Python found -> fast actionable failure (never an auto-download) run: | set +e - out=$(OWEN_PYTHON=/definitely/does/not/exist/python3 owen check "$RUNNER_TEMP/sample" 2>&1) + # --engine python is EXPLICIT (#262 Stage 3): a bare invocation now + # runs Rust and never consults OWEN_PYTHON. + out=$(OWEN_PYTHON=/definitely/does/not/exist/python3 owen check --engine python "$RUNNER_TEMP/sample" 2>&1) rc=$? set -e echo "$out" diff --git a/.github/workflows/shadow-sweep.yml b/.github/workflows/shadow-sweep.yml index 0f53be28..2b57e68a 100644 --- a/.github/workflows/shadow-sweep.yml +++ b/.github/workflows/shadow-sweep.yml @@ -155,6 +155,22 @@ jobs: - name: Build the dev-only engine adapter working-directory: rust run: cargo build --release -p own-shadow --bin own-shadow-engine + # #262 Stage 3: the extraction below invokes `own-check.sh` BARE, and the + # bare surface now runs Rust, so this leg needs a candidate or it exits 2 + # before writing a single fact. + # + # The candidate is supplied rather than the command changed. The + # extraction command is part of this sweep's frozen definition and is + # compared against it (tests/shadow_sweep.py), so adding `--engine python` + # here would have meant re-writing the definition and the recorded runs + # that match it. Supplying a candidate also keeps the extraction running + # the actual PUBLIC default, which for a gate about the public product is + # the better of the two. + - name: Build the production own-cli (the bare surface's default engine) + working-directory: rust + run: cargo build --release -p own-cli + - name: Select it + run: echo "OWEN_RUST_CORE=$PWD/rust/target/release/own-cli" >> "$GITHUB_ENV" # ONE extraction. `--emit-facts` persists the file stage 1 already wrote; # stage 2 is the reference's verdict path and is neither a second # extraction nor the comparison. @@ -243,6 +259,13 @@ jobs: - name: Build the dev-only engine adapter working-directory: rust run: cargo build --release -p own-shadow --bin own-shadow-engine + # #262 Stage 3, same reason as the matrix leg above: the bare surface's + # default engine is Rust, so this leg needs a candidate to extract at all. + - name: Build the production own-cli (the bare surface's default engine) + working-directory: rust + run: cargo build --release -p own-cli + - name: Select it + run: echo "OWEN_RUST_CORE=$PWD/rust/target/release/own-cli" >> "$GITHUB_ENV" - name: Extract the OwnIR facts, exactly once run: | scripts/own-check.sh --format sarif --severity warning \ diff --git a/.gitignore b/.gitignore index c102764b..cce242c9 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,13 @@ node_modules/ # Never commit other projects' code; promote interesting findings into # corpus/real-world/ as minimal reduced cases instead. corpus/mined/ + +# Cargo build directories, ignored explicitly at the root. `rust/.gitignore` +# already ignores `target/`, which is why `rust/target/` never entered the tree +# and why `rust/target-fault/` did: that pattern does not match a sibling with +# a different name. `target-fault` is the build directory of the +# fault-injection candidate (`cargo build -p own-cli --release --features +# fault-injection --target-dir target-fault`, ci.yml); commit 04c3303 tracked +# 223 files of it. Build output is not evidence and is never versioned. +/rust/target/ +/rust/target-fault/ diff --git a/README.md b/README.md index 2ef268e0..95b3a73a 100644 --- a/README.md +++ b/README.md @@ -90,10 +90,33 @@ git clone https://github.com/PhysShell/Own.NET && cd Own.NET scripts/own-check.sh --format human -- /path/to/your/csharp/repo ``` -Needs Python 3.11+ and the .NET SDK on `PATH` — nothing to build, nothing to -`pip install`. A packaged single-command CLI (`owen check`, package -`Owen.Cli`) also exists — build-and-install-locally today, not yet published -to nuget.org; see +Needs the .NET SDK on `PATH`, plus **one analysis engine**. Since P-022 Stage 3 +(#262) the default engine is the **Rust core**, and this script runs from a +checkout, so it needs a candidate binary — build one and point the ratified +locator at it: + +```bash +(cd rust && cargo build -p own-cli --release) +export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli" +``` + +…or skip the build entirely and run the **Python reference** engine, which needs +Python 3.11+ and is the documented rollback: + +```bash +scripts/own-check.sh --engine python --format human -- /path/to/your/csharp/repo +``` + +There is no discovery and no fallback: an unset or unusable `OWEN_RUST_CORE` is +a visible configuration error (exit 2) that tells you both of the above, never a +silent switch to the other engine. See +[`docs/notes/owen-engine-rollback.md`](docs/notes/owen-engine-rollback.md) for +the full engine-selection contract. + +The packaged single-command CLI (`owen check`, package `Owen.Cli`) needs none of +this: it **ships** the Rust core inside the package and resolves it itself, so a +bare `owen check` works with no environment variables and no Python at all. It +is build-and-install-locally today, not yet published to nuget.org; see [`frontend/roslyn/OwnSharp.Cli/README.md`](frontend/roslyn/OwnSharp.Cli/README.md) and [`docs/notes/alpha-readiness.md`](docs/notes/alpha-readiness.md) gate **A**. diff --git a/action.yml b/action.yml index fa8d0779..b4de10dd 100644 --- a/action.yml +++ b/action.yml @@ -34,16 +34,16 @@ inputs: default: "error" engine: description: >- - Which analysis engine runs (#262 Stage 1): python (the DEFAULT and the - reference), rust (the Rust core `own-cli ownir`), or compare (both over - one captured input, exposing the reference's result only when they agree - byte for byte). rust and compare require the candidate binary's absolute - path in the OWEN_RUST_CORE environment variable — the Action does no - discovery, so an unset or unusable OWEN_RUST_CORE is a configuration - error (exit 2) and never a silent fall back to Python. compare is a - development/CI seam for the migration, not yet a promised feature. + Which analysis engine runs. Since #262 Stage 3 the DEFAULT is rust (the + Rust core `own-cli ownir`); python selects the reference implementation + and is the documented ROLLBACK for the observation window; compare runs + both over one captured input and exposes the reference's result only when + they agree byte for byte. A Rust failure is never turned into a Python + success: there is no fallback, and selecting python is something you do + on purpose. compare is a development/CI seam for the migration, not yet a + promised feature. required: false - default: "python" + default: "rust" fail-on-finding: description: >- Whether a FINDING fails the step. Default false: findings are published @@ -80,7 +80,11 @@ outputs: runs: using: "composite" steps: - - name: Set up Python (Owen core) + # Python is still set up unconditionally, and on purpose: it is the + # documented ROLLBACK (engine: python) for the observation window, and + # `compare` needs both engines. Stage 3 changes which engine answers by + # default, not what the Action ships with. Removing this is Stage 4. + - name: Set up Python (Owen reference engine / rollback) uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} @@ -90,6 +94,98 @@ runs: with: dotnet-version: ${{ inputs.dotnet-version }} + # #262 Stage 3: the Rust core, for the engines that need one. + # + # The Action resolves its candidate by BUILDING the production `own-cli` + # crate from this action's own pinned ref. That is not a developer adapter + # and it is never own-shadow-engine or a stub: it is the same crate, the + # same --release profile and the same binary the release package carries, + # built from exactly the revision the caller pinned, which is also what + # makes the engine and the fixtures provably the same vintage. + # + # It is, however, a real cost to every consumer: a Rust toolchain and a + # first build. It is what is available today -- this repository publishes + # no release, so there is no `own-cli` artifact to download and no + # published Owen.Cli package to install -- and it should be revisited the + # moment a release exists, at which point downloading the released binary + # for the runner's platform is strictly better. Recorded rather than + # smoothed over, because the cost lands on users of this Action. + # + # OWNER RULING (#262 Stage 3): ACTION-BUILD is ACCEPTED for Stage 3 as a + # DECLARED TEMPORARY DISTRIBUTION COST. It is not a parity difference, not a + # semantic difference and not a Stage-3 blocker: the default is rust, Python + # stays the explicit rollback until Stage 4, the Rust toolchain is installed + # by this Action rather than assumed, and what is built is the production + # `own-cli` crate -- never own-shadow-engine and never a test adapter. So it + # is not an undeclared runtime dependency. It is simply a heavy way to + # deliver a binary: a consumer today pays setup-python, setup-dotnet, + # setup-rust and a cargo build to run a static analyzer. + # + # EXIT CONDITION: the first suitable published own-cli / Owen.Cli artifact. + # At that point this Action downloads an immutable platform binary and the + # consumer-side Rust build disappears. That is a separate post-Stage-3 + # packaging follow-up and deliberately NOT Stage 4, which is about removing + # the Python distribution dependency -- a different question. + # + # Skipped entirely for `engine: python`, so the rollback path stays as + # cheap as it was before Stage 3. + # THE PINNED QUALIFICATION TOOLCHAIN. This is the canonical place it is + # written down, and `tests/test_stage3_surfaces.py` refuses a floating + # channel here. + # + # `stable` was wrong for a consumer-facing surface, and wrong in a way that + # only shows up later: the action ref is pinned, the dependency graph is + # pinned by rust/Cargo.lock, and the compiler was the one input left + # drifting in time. A caller who pins PhysShell/Own.NET@ is entitled to + # have that tag mean one thing; with a moving channel the same tag would + # build with whatever rustc shipped that month, and a future release that + # compiled the crate differently -- or refused it -- would change the + # behaviour of a revision nobody touched. For a migration cutover that is a + # variable with no upside. + - name: Set up Rust (Owen core) + if: inputs.engine != 'python' + uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: "1.98.1" + - name: Cache the built Owen core + if: inputs.engine != 'python' + uses: actions/cache@v4 + with: + path: | + ${{ github.action_path }}/rust/target + ~/.cargo/registry + ~/.cargo/git + # The cache is an OPTIMIZATION; the Cargo build below is authoritative. + # + # An earlier comment here claimed this key gives a caller who bumps the + # pinned version a rebuild rather than a stale binary. It does not, and + # the claim was worth more than the key: `github.action_ref` is whatever + # the caller wrote, so a moving major tag like `v1` keeps one key across + # every commit it ever points at. What actually prevents a stale binary + # is Cargo -- it rebuilds a local crate whose sources changed, cache hit + # or not -- so the correctness argument belongs there and not here. + key: owen-core-${{ runner.os }}-${{ github.action_ref || github.sha }} + - name: Build the Owen core (own-cli) + if: inputs.engine != 'python' + shell: bash + run: | + set -euo pipefail + cd "${{ github.action_path }}/rust" + # --locked: rust/Cargo.lock is committed, so there is no reason for a + # production build on a consumer's runner to be allowed to recompute the + # dependency graph. Without it a yanked or newly published version can + # change what gets built from an unchanged source revision, and the + # build would succeed while quietly not being the qualified one. + cargo build -p own-cli --release --locked + ext="" + if [ "$RUNNER_OS" = "Windows" ]; then ext=".exe"; fi + core="$(cd target/release && pwd)/own-cli$ext" + # Prove it runs on THIS runner before anything depends on it, so a + # broken candidate is a clear failure here rather than an obscure one + # inside a check. + "$core" --version + echo "OWEN_RUST_CORE=$core" >> "$GITHUB_ENV" + - name: Owen leak check id: own shell: bash diff --git a/docs/evidence/p022-cli-1.json b/docs/evidence/p022-cli-1.json index c4ca974d..0fb093e0 100644 --- a/docs/evidence/p022-cli-1.json +++ b/docs/evidence/p022-cli-1.json @@ -342,7 +342,7 @@ "rule": "cli-b1-json-parser-detail", "description": "the CLI-B1 eligibility guard judges a file it reaches for instead of the bytes it was handed, so the negative control's two runs stop differing. This mutates the EVIDENCE, not the product, and it is here for one reason: #261 R3b replaced a negative control that read a different case at a different path, and a control held to one argv and one path is only worth more than that one if it actually fires when the guard stops reading the supplied bytes. Non-vacuity itself is a property of the control's construction — one case, two byte sequences — and is not mutation-provable; this shows the construction is load-bearing", "target": "rust/crates/own-cli/tests/replay.rs", - "pattern": " // Steps 1 and 2\\. Invalid UTF-8 belongs to ruling 1, never here\\.\\n let text = std::str::from_utf8\\(facts\\)\\.map_err\\(\\|e\\| \\{", + "pattern": " // Steps 1 and 2\\.\\n let text = std::str::from_utf8\\(facts\\)\\.map_err\\(\\|e\\| \\{", "replacement": " let facts = &std::fs::read(\n fixture_dir()\n .join(\"inputs\")\n .join(\"not_json_truncated.facts.broken\"),\n )\n .unwrap_or_default();\n let text = std::str::from_utf8(facts).map_err(|e| {", "expected_catchers": [ "own-cli/tests/replay.rs::cli_b1_flips_on_the_facts_bytes_and_nothing_else" diff --git a/docs/evidence/p022-cli-1.result.json b/docs/evidence/p022-cli-1.result.json index 55aa905d..926cc7ca 100644 --- a/docs/evidence/p022-cli-1.result.json +++ b/docs/evidence/p022-cli-1.result.json @@ -3,10 +3,10 @@ "comment": "Recorded mutation-campaign run (scripts/mutate_campaign.py --run). Raw facts only: outcomes, catchers, provenance. Counts are derived by scripts/render_checkpoint_status.py; regenerate this file by re-running the campaign, never by hand.", "campaign": "p022-cli-1", "definition": "docs/evidence/p022-cli-1.json", - "definition_sha256": "f86d83855bd7cd041cd7880370d3371142b9eff57b562061d44e9b2931fdb573", - "source_commit": "f932374ed8a5fc4731c37c3ce1cf5a071c1843a5", + "definition_sha256": "94afbda36698263dd0a7aab757c911c1193d39c587be8bf34dec50b45c13ec1b", + "source_commit": "b5d9272a0a858cb7fd80e91b3d72bce6b3fb5e84", "dirty": false, - "recorded_at": "2026-09-08T11:36:04Z", + "recorded_at": "2026-09-18T02:38:03Z", "layers": [ "own-cli", "own-cli-faults", @@ -18,7 +18,7 @@ "id": "M00", "outcome": "survived", "catchers": [], - "elapsed_seconds": 5.3 + "elapsed_seconds": 28.6 }, "mutations": [ { @@ -30,7 +30,7 @@ "own-cli/src/main.rs::ownir::tests::sarif_carries_shown_plus_suppressed", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 2.8 + "elapsed_seconds": 3.4 }, { "id": "M02", @@ -39,7 +39,7 @@ "own-cli/src/main.rs::ownir::tests::a_suppression_is_counted_but_does_not_fail_the_run", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 5.6 + "elapsed_seconds": 6.7 }, { "id": "M03", @@ -48,7 +48,7 @@ "own-cli/src/main.rs::ownir::tests::exit_is_independent_of_severity_and_verbosity", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 5.7 + "elapsed_seconds": 6.4 }, { "id": "M04", @@ -57,7 +57,7 @@ "own-cli/src/main.rs::ownir::tests::sarif_carries_shown_plus_suppressed", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 5.7 + "elapsed_seconds": 6.7 }, { "id": "M05", @@ -69,7 +69,7 @@ "own-cli/src/main.rs::ownir::tests::the_summary_pluralizes_on_anything_but_one", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 6.0 + "elapsed_seconds": 6.6 }, { "id": "M06", @@ -79,7 +79,7 @@ "own-cli/src/main.rs::ownir::tests::sarif_carries_shown_plus_suppressed", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 5.4 + "elapsed_seconds": 6.5 }, { "id": "M07", @@ -88,7 +88,7 @@ "own-cli/src/main.rs::ownir::tests::the_stream_split_is_per_format", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 5.5 + "elapsed_seconds": 6.4 }, { "id": "M08", @@ -96,7 +96,7 @@ "catchers": [ "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 5.4 + "elapsed_seconds": 6.4 }, { "id": "M09", @@ -105,7 +105,7 @@ "own-cli/src/main.rs::ownir::tests::the_verbose_breakdown_counts_suppressed_findings_too", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 5.8 + "elapsed_seconds": 6.5 }, { "id": "M10", @@ -115,7 +115,7 @@ "own-cli/src/main.rs::sarif::tests::hex_is_lowercase_and_padded", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 6.0 + "elapsed_seconds": 6.8 }, { "id": "M11", @@ -124,7 +124,7 @@ "own-cli/src/main.rs::ownir::tests::a_missing_flag_value_is_its_own_message", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 5.8 + "elapsed_seconds": 6.5 }, { "id": "M12", @@ -133,7 +133,7 @@ "own-cli-faults/tests/faults.rs::a_catchable_panic_is_one_diagnostic_and_exit_70", "own-cli-faults/tests/faults.rs::debug_mode_shows_the_backtrace_and_still_exits_70" ], - "elapsed_seconds": 6.1 + "elapsed_seconds": 6.5 }, { "id": "M13", @@ -142,7 +142,7 @@ "own-cli-faults/tests/faults.rs::a_catchable_panic_is_one_diagnostic_and_exit_70", "own-cli-faults/tests/faults.rs::debug_mode_shows_the_backtrace_and_still_exits_70" ], - "elapsed_seconds": 6.0 + "elapsed_seconds": 6.4 }, { "id": "M14", @@ -150,7 +150,7 @@ "catchers": [ "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 5.8 + "elapsed_seconds": 6.3 }, { "id": "M15", @@ -159,7 +159,7 @@ "own-cli/src/main.rs::ownir::tests::there_is_no_double_dash_separator", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 5.9 + "elapsed_seconds": 6.6 }, { "id": "M16", @@ -170,7 +170,7 @@ "own-ir/tests/version_repr_census.rs::the_declared_divergences_are_exactly_the_declared_ones", "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" ], - "elapsed_seconds": 6.3 + "elapsed_seconds": 7.3 }, { "id": "M17", @@ -179,7 +179,7 @@ "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte", "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" ], - "elapsed_seconds": 7.7 + "elapsed_seconds": 9.3 }, { "id": "M18", @@ -188,7 +188,7 @@ "own-cli/src/main.rs::ownir::tests::a_json_rejection_that_lost_its_prefix_is_an_internal_error_not_rc2", "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 7.8 + "elapsed_seconds": 9.1 }, { "id": "M19", @@ -196,7 +196,7 @@ "catchers": [ "own-cli/src/main.rs::ownir::tests::a_json_rejection_that_lost_its_prefix_is_an_internal_error_not_rc2" ], - "elapsed_seconds": 5.6 + "elapsed_seconds": 6.5 }, { "id": "M20", @@ -205,7 +205,7 @@ "own-ir/src/lib.rs::pyrepr::tests::a_repeated_key_is_rebound_in_place", "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" ], - "elapsed_seconds": 7.3 + "elapsed_seconds": 8.3 }, { "id": "M21", @@ -217,16 +217,17 @@ "own-ir/src/lib.rs::pyrepr::tests::the_literal_decides_int_or_float", "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" ], - "elapsed_seconds": 8.8 + "elapsed_seconds": 9.6 }, { "id": "M22", "outcome": "caught", "catchers": [ + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte", "own-ir/tests/version_repr_census.rs::negative_zero_below_the_top_level_matches_the_reference", "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" ], - "elapsed_seconds": 9.6 + "elapsed_seconds": 11.5 }, { "id": "M23", @@ -234,15 +235,18 @@ "catchers": [ "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" ], - "elapsed_seconds": 8.7 + "elapsed_seconds": 11.7 }, { "id": "M24", "outcome": "caught", "catchers": [ - "own-cli/tests/replay.rs::cli_b1_flips_on_the_facts_bytes_and_nothing_else" + "own-cli/tests/replay.rs::cli_b1_flips_on_the_facts_bytes_and_nothing_else", + "own-cli/tests/replay.rs::cli_b2_does_not_reach_a_nested_negative_zero", + "own-cli/tests/replay.rs::every_declared_boundary_case_is_eligible_for_the_boundary_it_names", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" ], - "elapsed_seconds": 6.2 + "elapsed_seconds": 7.6 } ] } diff --git a/docs/evidence/p022-stage1-1.json b/docs/evidence/p022-stage1-1.json index 26e3b21a..097ceff9 100644 --- a/docs/evidence/p022-stage1-1.json +++ b/docs/evidence/p022-stage1-1.json @@ -23,12 +23,12 @@ { "id": "M01", "rule": "default-is-python", - "description": "the Stage-1 default engine is Rust — the cutover read as already decided, instead of Python remaining default until Gate G3", + "description": "the public default engine goes back to Python -- the cutover silently REVERSED. Until #262 Stage 3 this mutant ran the other way (Python -> Rust, 'the covert Stage 3'); the cutover made that the correct state, so the defect it guards against turned over with it. What the mutant is FOR did not change: one edit to one constant moves what every user gets", "target": "frontend/roslyn/OwnSharp.Cli/EngineSelection.cs", - "pattern": "public const Engine Default = Engine\\.Python;", - "replacement": "public const Engine Default = Engine.Rust;", + "pattern": "public const Engine Default = Engine\\.Rust;", + "replacement": "public const Engine Default = Engine.Python;", "expected_catchers": [ - "stage1::default-stays-python" + "stage1::default-is-rust" ] }, { diff --git a/docs/evidence/p022-stage1-1.result.json b/docs/evidence/p022-stage1-1.result.json index 9d528c1b..94ab4c10 100644 --- a/docs/evidence/p022-stage1-1.result.json +++ b/docs/evidence/p022-stage1-1.result.json @@ -3,10 +3,10 @@ "comment": "Recorded mutation-campaign run (scripts/mutate_campaign.py --run). Raw facts only: outcomes, catchers, provenance. Counts are derived by scripts/render_checkpoint_status.py; regenerate this file by re-running the campaign, never by hand.", "campaign": "p022-stage1-1", "definition": "docs/evidence/p022-stage1-1.json", - "definition_sha256": "c8bd2180f285a130dfadc153e966e48b2c2d8d6072d90651906112719c0040eb", - "source_commit": "da897b42fcd76ea1914d286bd6bae6065c072c70", + "definition_sha256": "076165178704095f9d0d2fbec1efea37256831ba4f92c62c71534576b27f5f85", + "source_commit": "f988c8e1be7ff12a99b46d5bff444c68d00b8743", "dirty": false, - "recorded_at": "2026-09-09T08:36:00Z", + "recorded_at": "2026-09-18T04:22:24Z", "layers": [ "stage1" ], @@ -15,44 +15,48 @@ "id": "M00", "outcome": "survived", "catchers": [], - "elapsed_seconds": 34.4 + "elapsed_seconds": 39.0 }, "mutations": [ { "id": "M01", "outcome": "caught", "catchers": [ - "stage1::default-stays-python" + "stage1::default-is-rust", + "stage1::unset-locator-is-d6" ], - "elapsed_seconds": 27.6 + "elapsed_seconds": 32.1 }, { "id": "M02", "outcome": "caught", "catchers": [ + "stage1::default-is-rust", "stage1::rust-actually-runs-rust" ], - "elapsed_seconds": 25.5 + "elapsed_seconds": 29.3 }, { "id": "M03", "outcome": "caught", "catchers": [ + "stage1::default-is-rust", "stage1::rc70-is-not-a-verdict", "stage1::rust-failure-no-fallback" ], - "elapsed_seconds": 27.3 + "elapsed_seconds": 31.3 }, { "id": "M04", "outcome": "caught", "catchers": [ + "stage1::default-is-rust", "stage1::raw-rc-retained", "stage1::rc70-is-not-a-verdict", "stage1::rust-failure-no-fallback", "stage1::unexpected-rc-maps-to-5" ], - "elapsed_seconds": 27.1 + "elapsed_seconds": 31.9 }, { "id": "M05", @@ -60,25 +64,27 @@ "catchers": [ "stage1::raw-rc-retained" ], - "elapsed_seconds": 27.7 + "elapsed_seconds": 32.2 }, { "id": "M06", "outcome": "caught", "catchers": [ "stage1::absolute-locator-only", - "stage1::bad-locator-is-2" + "stage1::bad-locator-is-2", + "stage1::unset-locator-is-d6" ], - "elapsed_seconds": 26.9 + "elapsed_seconds": 31.8 }, { "id": "M07", "outcome": "caught", "catchers": [ "stage1::absolute-locator-only", - "stage1::bad-locator-is-2" + "stage1::bad-locator-is-2", + "stage1::unset-locator-is-d6" ], - "elapsed_seconds": 27.1 + "elapsed_seconds": 31.5 }, { "id": "M08", @@ -86,7 +92,7 @@ "catchers": [ "stage1::rust-failure-no-fallback" ], - "elapsed_seconds": 26.7 + "elapsed_seconds": 31.7 }, { "id": "M09", @@ -95,7 +101,7 @@ "stage1::absolute-locator-only", "stage1::bad-locator-is-2" ], - "elapsed_seconds": 27.4 + "elapsed_seconds": 31.7 }, { "id": "M10", @@ -105,7 +111,7 @@ "stage1::compare-same-input", "stage1::divergence-is-5" ], - "elapsed_seconds": 25.8 + "elapsed_seconds": 31.2 }, { "id": "M11", @@ -114,7 +120,7 @@ "stage1::compare-failure-classified", "stage1::exec-failure-is-5" ], - "elapsed_seconds": 26.2 + "elapsed_seconds": 32.1 }, { "id": "M12", @@ -122,7 +128,7 @@ "catchers": [ "stage1::compare-zero-document" ], - "elapsed_seconds": 26.9 + "elapsed_seconds": 32.0 }, { "id": "M13", @@ -130,7 +136,7 @@ "catchers": [ "stage1::candidate-identity" ], - "elapsed_seconds": 26.7 + "elapsed_seconds": 32.1 }, { "id": "M14", @@ -139,7 +145,7 @@ "stage1::compare-no-substitution", "stage1::divergence-is-5" ], - "elapsed_seconds": 25.9 + "elapsed_seconds": 32.1 }, { "id": "M15", @@ -147,7 +153,7 @@ "catchers": [ "stage1::divergence-is-5" ], - "elapsed_seconds": 26.7 + "elapsed_seconds": 32.5 }, { "id": "M16", @@ -155,7 +161,7 @@ "catchers": [ "stage1::compare-zero-document" ], - "elapsed_seconds": 26.1 + "elapsed_seconds": 32.0 }, { "id": "M17", @@ -164,7 +170,7 @@ "stage1::absolute-locator-only", "stage1::locator-shapes" ], - "elapsed_seconds": 28.7 + "elapsed_seconds": 34.3 }, { "id": "M18", @@ -173,7 +179,7 @@ "stage1::absolute-locator-only", "stage1::locator-shapes" ], - "elapsed_seconds": 31.0 + "elapsed_seconds": 37.2 }, { "id": "M19", @@ -181,7 +187,7 @@ "catchers": [ "stage1::compare-failure-classified" ], - "elapsed_seconds": 26.5 + "elapsed_seconds": 32.2 }, { "id": "M20", @@ -195,7 +201,7 @@ "stage1::locator-shapes", "stage1::rust-failure-no-fallback" ], - "elapsed_seconds": 13.0 + "elapsed_seconds": 17.6 }, { "id": "M21", @@ -206,6 +212,7 @@ "stage1::compare-failure-classified", "stage1::compare-same-input", "stage1::compare-zero-document", + "stage1::default-is-rust", "stage1::divergence-is-5", "stage1::exec-failure-is-5", "stage1::locator-shapes", @@ -215,7 +222,7 @@ "stage1::rust-failure-no-fallback", "stage1::unexpected-rc-maps-to-5" ], - "elapsed_seconds": 17.2 + "elapsed_seconds": 19.9 } ] } diff --git a/docs/evidence/p022-stage1-windows.json b/docs/evidence/p022-stage1-windows.json index 8b656484..ae4032a5 100644 --- a/docs/evidence/p022-stage1-windows.json +++ b/docs/evidence/p022-stage1-windows.json @@ -101,10 +101,10 @@ { "id": "P07", "rule": "the-candidate-is-spawned-not-opened", - "description": "own-check.ps1 goes back to invoking the candidate with the call operator — 'PowerShell runs it either way, why the ceremony?'. It does not run it: it asks the platform to OPEN it, so a file the loader cannot start is handed to a desktop handler (notepad on Windows, xdg-open on Linux), the run exits 0 with empty streams, and Owen reports a clean finding-free analysis of nothing", + "description": "own-check.ps1 goes back to invoking the candidate with the call operator -- 'PowerShell runs it either way, why the ceremony?'. It does not run it: it asks the platform to OPEN it, so a file the loader cannot start is handed to a desktop handler (notepad on Windows, xdg-open on Linux), the run exits 0 with empty streams, and own-check.ps1 reports a clean finding-free scan having analysed nothing. Re-anchored at #262 Stage 3, when this function grew stream redirection: the candidate's output has to reach PowerShell's PIPELINE or a caller capturing this script gets nothing, so the spawn now returns a result object. The defect the mutant describes is unchanged -- only the lines it replaces moved.", "target": "scripts/own-check.ps1", - "pattern": " \\$psi\\.UseShellExecute = \\$false\n \\$proc = \\[System\\.Diagnostics\\.Process\\]::Start\\(\\$psi\\)\n \\$proc\\.WaitForExit\\(\\)\n return \\$proc\\.ExitCode", - "replacement": " & $FilePath @ArgumentList\n return $LASTEXITCODE", + "pattern": " \\$psi\\.UseShellExecute = \\$false\n \\$psi\\.RedirectStandardOutput = \\$true", + "replacement": " $out = & $FilePath @ArgumentList\n return [pscustomobject]@{ ExitCode = $LASTEXITCODE; StdOut = ($out -join \"`n\"); StdErr = \"\" }\n $psi.UseShellExecute = $false\n $psi.RedirectStandardOutput = $true", "expected_catchers": [ "ps1::ps1-not-started-is-2" ] diff --git a/docs/evidence/p022-stage1-windows.result.json b/docs/evidence/p022-stage1-windows.result.json index d0fa7154..0593b874 100644 --- a/docs/evidence/p022-stage1-windows.result.json +++ b/docs/evidence/p022-stage1-windows.result.json @@ -3,10 +3,10 @@ "comment": "Recorded mutation-campaign run (scripts/mutate_campaign.py --run). Raw facts only: outcomes, catchers, provenance. Counts are derived by scripts/render_checkpoint_status.py; regenerate this file by re-running the campaign, never by hand.", "campaign": "p022-stage1-windows", "definition": "docs/evidence/p022-stage1-windows.json", - "definition_sha256": "6dbfa6054f6447bb41886b30b1b1ade3dcb9e1be0fb874dc5795b7775c30ebc3", - "source_commit": "da897b42fcd76ea1914d286bd6bae6065c072c70", + "definition_sha256": "656c5e69e4ec01672928d8c812ad7115274cfb032c1d29afe529fa9b80b88b5b", + "source_commit": "baf3771cd0106bf9a2242261c203445cf24f85c7", "dirty": false, - "recorded_at": "2026-09-09T08:33:54Z", + "recorded_at": "2026-09-18T05:00:44Z", "layers": [ "ps1", "shapes" @@ -16,7 +16,7 @@ "id": "M00", "outcome": "survived", "catchers": [], - "elapsed_seconds": 58.9 + "elapsed_seconds": 57.6 }, "mutations": [ { @@ -25,7 +25,7 @@ "catchers": [ "ps1::ps1-absolute-locator" ], - "elapsed_seconds": 36.4 + "elapsed_seconds": 28.5 }, { "id": "P02", @@ -33,7 +33,7 @@ "catchers": [ "ps1::ps1-not-started-is-2" ], - "elapsed_seconds": 28.8 + "elapsed_seconds": 24.5 }, { "id": "P03", @@ -41,7 +41,7 @@ "catchers": [ "ps1::ps1-agreement-replays" ], - "elapsed_seconds": 31.6 + "elapsed_seconds": 22.9 }, { "id": "P04", @@ -49,15 +49,16 @@ "catchers": [ "ps1::ps1-failure-evidence" ], - "elapsed_seconds": 32.7 + "elapsed_seconds": 22.2 }, { "id": "P05", "outcome": "caught", "catchers": [ + "ps1::ps1-agreement-replays", "shapes::locator-shapes" ], - "elapsed_seconds": 31.1 + "elapsed_seconds": 12.9 }, { "id": "P06", @@ -68,7 +69,7 @@ "ps1::ps1-failure-evidence", "ps1::ps1-not-started-is-2" ], - "elapsed_seconds": 18.5 + "elapsed_seconds": 13.2 }, { "id": "P07", @@ -76,7 +77,7 @@ "catchers": [ "ps1::ps1-not-started-is-2" ], - "elapsed_seconds": 29.5 + "elapsed_seconds": 21.1 }, { "id": "P08", @@ -84,7 +85,7 @@ "catchers": [ "ps1::ps1-agreement-replays" ], - "elapsed_seconds": 28.8 + "elapsed_seconds": 20.6 }, { "id": "P09", @@ -92,7 +93,7 @@ "catchers": [ "ps1::ps1-absolute-locator" ], - "elapsed_seconds": 58.2 + "elapsed_seconds": 42.0 } ] } diff --git a/docs/evidence/p022-stage2-1.json b/docs/evidence/p022-stage2-1.json index 561d4e9a..c3c97483 100644 --- a/docs/evidence/p022-stage2-1.json +++ b/docs/evidence/p022-stage2-1.json @@ -81,23 +81,23 @@ { "id": "S06", "rule": "the-public-default-does-not-move", - "description": "the PRODUCT default flips to Rust — the covert Stage 3, and the single edit that would make every internal Rust job pass by accident while changing what every user gets", + "description": "the PRODUCT default goes back to Python -- the cutover silently reversed through the C# launcher. Inverted at #262 Stage 3: it used to mutate Python -> Rust, which is now the shipped state", "target": "frontend/roslyn/OwnSharp.Cli/EngineSelection.cs", - "pattern": "public const Engine Default = Engine\\.Python;", - "replacement": "public const Engine Default = Engine.Rust;", + "pattern": "public const Engine Default = Engine\\.Rust;", + "replacement": "public const Engine Default = Engine.Python;", "expected_catchers": [ - "stage2::public-default-moved" + "stage2::public-default-is-rust" ] }, { "id": "S07", "rule": "the-public-default-does-not-move", - "description": "the ACTION's public engine input defaults to rust — the same cutover through the other public door, and the one a C#-only control would miss", + "description": "the ACTION's public engine input goes back to python -- the same reversal through the other public door, and the one a C#-only control would miss. Inverted at #262 Stage 3 for the same reason as S06", "target": "action.yml", - "pattern": " required: false\n default: \"python\"", - "replacement": " required: false\n default: \"rust\"", + "pattern": " required: false\n default: \"rust\"", + "replacement": " required: false\n default: \"python\"", "expected_catchers": [ - "stage2::public-default-moved" + "stage2::public-default-is-rust" ] }, { diff --git a/docs/evidence/p022-stage2-1.result.json b/docs/evidence/p022-stage2-1.result.json index 44c38734..704a8b13 100644 --- a/docs/evidence/p022-stage2-1.result.json +++ b/docs/evidence/p022-stage2-1.result.json @@ -3,10 +3,10 @@ "comment": "Recorded mutation-campaign run (scripts/mutate_campaign.py --run). Raw facts only: outcomes, catchers, provenance. Counts are derived by scripts/render_checkpoint_status.py; regenerate this file by re-running the campaign, never by hand.", "campaign": "p022-stage2-1", "definition": "docs/evidence/p022-stage2-1.json", - "definition_sha256": "fd96f37ba882a9b94e7bedf4158574816702ee2023936b7ef27f56656740af3b", - "source_commit": "345cf04fe9f9880a2b316f220b565412e9cb9855", + "definition_sha256": "51118fd9c55f5825d2753451662ca75981e180fe615164cbb16c7cc488180fdd", + "source_commit": "0d6686eddc91613385ce39bccf3f581e9d7a7f05", "dirty": false, - "recorded_at": "2026-09-09T15:14:43Z", + "recorded_at": "2026-09-18T04:30:28Z", "layers": [ "stage2" ], @@ -15,7 +15,7 @@ "id": "M00", "outcome": "survived", "catchers": [], - "elapsed_seconds": 8.6 + "elapsed_seconds": 10.3 }, "mutations": [ { @@ -24,7 +24,7 @@ "catchers": [ "stage2::internal-default-not-rust" ], - "elapsed_seconds": 6.0 + "elapsed_seconds": 6.9 }, { "id": "S02", @@ -32,7 +32,7 @@ "catchers": [ "stage2::wrong-rust-candidate" ], - "elapsed_seconds": 6.0 + "elapsed_seconds": 6.9 }, { "id": "S03", @@ -40,7 +40,7 @@ "catchers": [ "stage2::platform-leg-lost" ], - "elapsed_seconds": 6.0 + "elapsed_seconds": 7.1 }, { "id": "S04", @@ -48,7 +48,7 @@ "catchers": [ "stage2::locator-contract-bypassed" ], - "elapsed_seconds": 6.1 + "elapsed_seconds": 6.9 }, { "id": "S05", @@ -56,23 +56,23 @@ "catchers": [ "stage2::rust-job-falls-back" ], - "elapsed_seconds": 6.3 + "elapsed_seconds": 6.8 }, { "id": "S06", "outcome": "caught", "catchers": [ - "stage2::public-default-moved" + "stage2::public-default-is-rust" ], - "elapsed_seconds": 6.1 + "elapsed_seconds": 6.9 }, { "id": "S07", "outcome": "caught", "catchers": [ - "stage2::public-default-moved" + "stage2::public-default-is-rust" ], - "elapsed_seconds": 6.3 + "elapsed_seconds": 7.1 }, { "id": "S08", @@ -80,7 +80,7 @@ "catchers": [ "stage2::compare-gate-dropped" ], - "elapsed_seconds": 6.3 + "elapsed_seconds": 6.9 }, { "id": "S09", @@ -88,7 +88,7 @@ "catchers": [ "stage2::stage2-census" ], - "elapsed_seconds": 6.0 + "elapsed_seconds": 7.0 }, { "id": "S10", @@ -96,7 +96,7 @@ "catchers": [ "stage2::stage2-census" ], - "elapsed_seconds": 6.1 + "elapsed_seconds": 7.0 }, { "id": "S11", @@ -104,7 +104,7 @@ "catchers": [ "stage2::internal-default-not-rust" ], - "elapsed_seconds": 5.9 + "elapsed_seconds": 6.9 } ] } diff --git a/docs/evidence/p022-stage2-census.json b/docs/evidence/p022-stage2-census.json index 04ec854c..9150f8bd 100644 --- a/docs/evidence/p022-stage2-census.json +++ b/docs/evidence/p022-stage2-census.json @@ -1,8 +1,8 @@ { "schema": 1, - "comment": "GENERATED-BY-HAND ledger, ENFORCED by tests/test_stage2_dogfood.py. #262 Stage 2 claims that Own.NET's own CI and dogfood select Rust by default. A claim like that is worth nothing without a denominator, so this file names EVERY CI call site that executes the Owen analysis core or a production launcher surface, and the role each one plays. The harness enumerates the workflows itself: a call site missing from this ledger fails, and a ledger entry whose job no longer invokes anything fails too. Neither direction can be satisfied by editing prose.", + "comment": "GENERATED-BY-HAND ledger, ENFORCED by tests/test_stage2_dogfood.py. #262 Stage 2 claimed that Own.NET's own CI and dogfood select Rust by default; #262 Stage 3 moved the PUBLIC default to Rust as well. The ledger survives both because it classifies by ROLE rather than by engine: what a call site is for does not change when the default does. A claim like that is worth nothing without a denominator, so this file names EVERY CI call site that executes the Owen analysis core or a production launcher surface, and the role each one plays. The harness enumerates the workflows itself: a call site missing from this ledger fails, and a ledger entry whose job no longer invokes anything fails too. Neither direction can be satisfied by editing prose.", "classes": { - "A": "PUBLIC-CONTRACT VERIFIER. Exists to prove how the externally visible, packaged surface behaves. Its bare invocations run the PUBLIC default on purpose, so it stays Python-default: switching it would delete the evidence that the public default did not move. Not a counterexample to the Stage-2 claim — its whole job is the opposite.", + "A": "PUBLIC-CONTRACT VERIFIER. Exists to prove how the externally visible, packaged surface behaves. Its bare invocations run the PUBLIC default on purpose. That default was Python through Stages 1 and 2 and is RUST from #262 Stage 3, so what this class asserts turned over with the cutover while its job did not change: it still verifies the public default, and it is still the class that would catch the default moving without authorization. A Class-A site therefore needs a resolvable Rust candidate from Stage 3 on -- the packaged one for an installed surface (D6), an explicitly built one for a checkout surface -- and a Class-A site that silently kept working without one would be evidence that the cutover did not reach it.", "B": "EXPLICIT PYTHON REFERENCE / deliberately engine-specific measurement. Parity, fixture semantics, benchmark methodology or a cross-tool oracle, where the reference implementation IS the instrument. Switching the engine would silently redefine what the measurement means. Anything that actually runs BOTH engines as a gate is C, not B.", "C": "COMPARE GATE. Runs both engines under the ratified #260 / Stage-1 differential contract. Stage 2 increases Rust exposure and must never pay for it with differential evidence, so these are neither weakened nor relabelled.", "D": "OPERATIONAL SELF-DOGFOOD. Own.NET running Owen over its own code as the engine under test — not verifying the public default, not acting as a reference. This is the Stage-2 Rust-default population: every Class-D call site selects Rust EXPLICITLY through the ratified launcher contract, with the production `own-cli` supplied via OWEN_RUST_CORE." diff --git a/docs/evidence/p022-stage3-cutover.json b/docs/evidence/p022-stage3-cutover.json new file mode 100644 index 00000000..a6974fdf --- /dev/null +++ b/docs/evidence/p022-stage3-cutover.json @@ -0,0 +1,287 @@ +{ + "schema": 1, + "comment": "GENERATED-BY-HAND ledger of the #262 Stage-3 cutover measurements, ENFORCED by tests/test_stage3_packet.py and rendered into docs/generated/p022-stage3-packet.md by scripts/stage3_packet.py. Every row names WHAT was asked, WHERE it was taken and WHAT it returned, and carries an evidence label. A row whose platform is 'windows' and whose result is 'OWED' is exactly that: not measured yet, never a prediction from the Linux row beside it. The packet is derived from this file and never typed.", + "stage": "P-022 Stage 3 — public Rust-default cutover", + "candidate_sha": "b22543680da9e8fa6b2607435bb54a5437815bff", + "owner_ruling_performance": "Performance evidence is deferred by owner for the Stage-3 public-default decision. Stage 3 makes no performance claim. #263 remains required before any published/reproducible performance claim and remains the performance-baseline tracker.", + "deferred_fields": { + "startup_delta": "DEFERRED BY OWNER — NOT MEASURED", + "end_to_end_delta": "DEFERRED BY OWNER — NOT MEASURED", + "peak_memory_delta": "DEFERRED BY OWNER — NOT MEASURED" + }, + "supported_platforms": { + "comment": "The set the release workflow actually builds and smoke-tests, not a wider claim. Naming it here is what lets a packaging row say 'every supported platform' without that phrase quietly meaning whatever the reader assumes.", + "keys": [ + "linux-x64", + "win-x64" + ] + }, + "measurements": [ + { + "id": "fast-compare", + "question": "does the committed corpus agree through both engines?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "scripts/shadow_compare.py --engine compare --corpus, adapter own-shadow-engine", + "result": "104 documents, 104 agreed, 0 acceptance-unexplained" + }, + { + "id": "samples-compare", + "question": "do the extracted C# sample facts agree?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "extract once via OwnSharp.Extractor, then shadow_compare over those bytes", + "result": "1 document, 1 agreed, 0 acceptance-unexplained" + }, + { + "id": "compare-driver-controls", + "question": "is the compare driver itself honest?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "tests/test_shadow_compare.py with OWN_SHADOW_COMPARE_REQUIRED=1", + "result": "10 double-driven + 6 manifest/identity + 5 raw-variant + 3 negative controls held" + }, + { + "id": "broad-sweep", + "question": "do the five pinned OSS repositories and the large solutions agree?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "the frozen #260 sweep definition, re-taken on the candidate tree; see p022-stage3-sweep.result.json", + "result": "10 documents over 6 targets, all agreed, 0 acceptance-unexplained, 0 declared-boundary" + }, + { + "id": "cli-replay", + "question": "does the production own-cli still reproduce the frozen CLI contract?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "rust/crates/own-cli/tests/replay.rs against the built binary, zero Python", + "result": "94 frozen cases replayed byte-for-byte; 2 declared boundaries (CLI-B1, CLI-B2)" + }, + { + "id": "hygiene-tails", + "question": "are the three Python-first tails closed on the production path?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "tests/test_ownir_input_boundary.py through `python -m ownlang ownir`, plus the CLI replay", + "result": "invalid UTF-8 rc 70 -> rc 2 (byte parity); V1 moved to the JSON door; V2 top-level -0 was ACCEPTED as v0 and is now refused; nested -0 unmoved" + }, + { + "id": "packaging-linux", + "question": "does the packed public artifact run Rust with no Python runtime?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "dotnet pack -p:OwenRustCoreDir, install from an isolated feed into a scratch tool-path, run with a PATH carrying no python", + "result": "bare `owen check` -> OWN001, rc 1, no Python present; --engine python -> same finding; packaged binary chmod 644 -> materialised to ~/.owen/rust-core/ and ran; packaged binary absent -> rc 2 denying a fallback" + }, + { + "id": "packaging-windows", + "question": "the same, natively on Windows", + "platform": "windows", + "label": "MEASURED OBSERVATION", + "method": "CI job `owen CLI (gate A)` on windows-latest (run 35304488082, job 105473759598, at 2ecf501), which builds own-cli natively on that runner, packs it into the Owen.Cli nupkg, installs the PACKED artifact from an isolated feed and then runs it", + "result": "PASS. The job concluded success, and it carries the unconditional Stage-3 assertion that a bare `owen check` with a deliberately unusable OWEN_PYTHON still finds OWN001 at rc 1 and never consults the development locator -- under `bash -e` with an explicit `exit 1`, so a success conclusion is that step passing. Nothing here is carried over from the Linux row: this is windows-latest reporting on itself. Its install -> check -> findings, clean-code, uninstall/reinstall and vendored-cache steps passed in the same job" + }, + { + "id": "cancellation-linux", + "question": "what does an interrupted engine do?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "tests/test_stage3_cancellation.py, both engines interrupted while genuinely running", + "result": "python reference: SIGINT -> died by signal 2, stdout 0b, stderr 1478b (a KeyboardInterrupt traceback); rust own-cli: SIGINT -> died by signal 2, stdout 0b, stderr 0b. Neither has an exit code in that state; neither returned a verdict" + }, + { + "id": "cancellation-windows", + "question": "the same, natively on Windows", + "platform": "windows", + "label": "MEASURED OBSERVATION", + "method": "tests/test_stage3_cancellation.py on windows-latest, CI job 105482499346 at f69f098, sending CTRL_BREAK_EVENT to a child process group; https://github.com/PhysShell/Own.NET/actions/runs/35307467735", + "result": "PASS, and the disposition is NOTHING like the Linux one, which is why #262 forbade inventing a universal 130. Both engines interrupted while genuinely running: python reference CTRL_BREAK_EVENT -> EXITED with code 3221225786 (0xC000013A, STATUS_CONTROL_C_EXIT), stdout 0b, stderr 0b; rust own-cli the same, 3221225786, stdout 0b, stderr 0b. On Linux both DIE BY SIGNAL 2 and have no exit code at all. Neither platform is 130. Invariants held on both: terminated, never a verdict code (0/1), no ok line, no findings summary" + }, + { + "id": "rollback-linux", + "question": "do the four rollback states stay distinct?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "tests/test_stage3_rollback.py over own-check.sh and owen", + "result": "all four held on both surfaces: default=Rust; explicit python agrees on the verdict; broken candidate with nothing asked -> rc 2 denying a fallback; broken candidate with python asked -> python runs" + }, + { + "id": "rollback-windows", + "question": "the same, natively on Windows", + "platform": "windows", + "label": "MEASURED OBSERVATION", + "method": "tests/test_stage3_rollback.py on windows-latest, CI job 105482499346 at f69f098; https://github.com/PhysShell/Own.NET/actions/runs/35307467735", + "result": "PASS. All four states held on both runnable surfaces (own-check.sh under git-bash, and owen): default=Rust; explicit python agrees on the verdict; broken candidate with nothing asked -> exit 2 denying a fallback in as many words; broken candidate with python asked -> python runs anyway" + }, + { + "id": "stage1-controls", + "question": "do the Stage-1 engine-seam controls still hold after the cutover?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "tests/test_stage1_engine.py with the full toolchain and OWEN_STAGE1_REQUIRE=1", + "result": "19 controls passed, 0 failed, 0 skipped — including default-is-rust and unset-locator-is-d6" + }, + { + "id": "stage2-controls", + "question": "is the CI/dogfood census still true and complete?", + "platform": "linux", + "label": "MEASURED OBSERVATION", + "method": "tests/test_stage2_dogfood.py", + "result": "9 controls passed, including public-default-is-rust" + }, + { + "id": "ci-surfaces", + "question": "can every CI launcher invocation reach an engine, and does any of them test nothing?", + "platform": "n/a", + "label": "MEASURED OBSERVATION", + "method": "tests/test_stage3_surfaces.py over every workflow and script", + "result": "63 invocations over 26 files; 23 explicit, 40 bare and all resolvable; 0 hollow Python injections" + }, + { + "id": "stage1-controls-windows", + "question": "do the Stage-1 engine-seam controls hold natively on Windows after the cutover?", + "platform": "windows", + "label": "MEASURED OBSERVATION", + "method": "CI job `owen --engine (#262 Stage 1 controls)` on windows-latest (run 35304488082, job 105473759377, at 2ecf501); https://github.com/PhysShell/Own.NET/actions/runs/35304488082", + "result": "19 controls passed, 0 failed, 0 skipped, 1 not applicable (a non-executable candidate cannot be constructed for the shell surface under git-bash). Includes default-is-rust, unset-locator-is-d6, rust-failure-no-fallback and an unexpected child status mapped to public exit 5 with the raw WINDOWS-NATIVE status (-1073740791) retained. The job as a whole went red on a HARNESS defect in the Stage-3 rollback control that ran after these, not on any of them" + }, + { + "id": "terminal-ci", + "question": "does the whole matrix pass on the implementation candidate itself, the cleaned head?", + "platform": "n/a", + "label": "MEASURED OBSERVATION", + "method": "CI run 35320877147 at b22543680da9; https://github.com/PhysShell/Own.NET/actions/runs/35320877147 -- the same push-triggered ci.yml matrix as the decision run 35312190310 at f1d3681e96e3, re-run on the cleaned head", + "result": "31/31 jobs green, 0 failures, 0 non-success, on b22543680da9 -- the implementation qualification point. The Stage-3 decision was established on f1d3681e96e3 (run 35312190310, 31/31); the cleaned head differs from it by the reconciliation record 7622a3b and the repository-hygiene commit b225436, which untracked 223 files of Cargo build output under rust/target-fault, so the matrix was re-run rather than inherited. The terminal MERGE qualification is CI on the reconciliation-only head that carries this record; its canonical SHA and run are recorded on #262, never inside the commit that creates the SHA. Includes both Stage-1 control legs, both packed-artifact legs, both dogfood legs, the Windows-native and Stage-2 Windows campaigns, own-check.ps1's exit-code tiers, both shadow-compare gates, the Rust-default code-scanning dogfood, rust fmt/clippy/tests and the Python suite on 3.11/3.12/3.13" + } + ], + "known_differences": [ + { + "id": "WIN-ABC", + "status": "carried forward from #262, unchanged", + "summary": "Windows canonical UTF-8. A: canonical reference parity CLAIMED. B: Rust portability CLAIMED. C: native-Windows Python parity NOT CLAIMED — the reference emits cp1252/CRLF there and can fail with UnicodeEncodeError, so a Windows user's bytes change at cutover. This is a DECLARED BEHAVIOR CHANGE, not a regression, and it bit a Stage-3 control that had compared raw bytes across engines on Windows; that control now compares the verdict." + }, + { + "id": "CLI-B1", + "status": "carried forward, now 6 cases", + "summary": "JSON_PARSER_DETAIL. Applies iff the strict door's kind is Json. The full CLI-owned wrapper is pinned byte-exact; only the parser library's own text after it is declared." + }, + { + "id": "CLI-B2", + "status": "NEW, and a NARROWING of #262's V2", + "summary": "TOP_LEVEL_NEGATIVE_ZERO. Was an ACCEPT-versus-REJECT divergence: the reference read a top-level `-0` as the integer 0 and analysed the document as v0, while serde_json refused it. The Python-first hygiene fix makes both sides refuse; what is left declared is how each spells the token it refused (`-0` vs `-0.0`), at the same exit code and the same door. Reconciling it would mean teaching one parser the other's reading of `-0`, which #262 ruled out and #260 froze a refusal for." + }, + { + "id": "V4", + "status": "carried forward, unchanged, reopen predicate verified NOT fired", + "summary": "str.isprintable() is answered from a version-dependent Unicode table. A representation/diagnostic boundary only, excluded from the byte-parity denominator and separately measured. Verified on this tree: the supported Python matrix is still 3.11/3.12/3.13 and the Rust snapshot is still unicode-properties 0.1.4." + }, + { + "id": "CANCEL-TRACEBACK", + "status": "NEW, measured, and LINUX-ONLY", + "summary": "On LINUX an interrupted Python reference prints a KeyboardInterrupt traceback (1478 bytes measured) while the Rust core prints nothing, so a cancelled run stops printing a stack trace after the cutover. On WINDOWS neither engine prints anything (stderr 0b for both): the default CTRL_BREAK handler terminates the process before Python's handler runs. Measured on each platform rather than carried across -- the difference exists on one of them and not the other, which a single-platform measurement would have got wrong in either direction. User-visible, caused by the cutover, recorded rather than fixed: Stage 3 changes the default engine, not the reference." + }, + { + "id": "ACTION-BUILD", + "status": "OWNER RULING: ACCEPTED FOR STAGE 3 — a declared temporary distribution cost", + "summary": "The Owen Action builds the production `own-cli` from its own pinned ref, because this repository publishes no release: there is no own-cli artifact to download and no published Owen.Cli package to install. Ruled NOT a parity difference, NOT a semantic difference and NOT a Stage-3 blocker -- the default is rust, Python stays the explicit rollback until Stage 4, the Rust toolchain is installed by the Action rather than assumed, and what is built is the production crate, never own-shadow-engine or a test adapter. So it is not an undeclared runtime dependency; it is a heavy way to deliver a binary. A consumer today pays setup-python, setup-dotnet, setup-rust and a cargo build to run a static analyzer. EXIT CONDITION: the first suitable published own-cli/Owen.Cli artifact, at which point the Action downloads an immutable platform binary and the consumer-side Rust build disappears -- a separate post-Stage-3 packaging follow-up, deliberately NOT Stage 4, which is about removing the Python distribution dependency. HARDENED for reproducibility at the ruling: rustc pinned to the concrete qualification toolchain (1.98.1) instead of the moving `stable` channel, and `cargo build --locked` so an unchanged source revision cannot silently resolve a different dependency graph. The build cache is an optimization and the Cargo build is authoritative: an earlier comment claimed the cache key gives a caller who bumps a pinned version a rebuild, which it does not -- a moving major tag keeps one key across every commit it points at." + } + ], + "closed_in_this_change": [ + { + "id": "UTF8-TAIL", + "was": "#262 known difference: invalid UTF-8 escaped the reference's load() and surfaced as rc 70", + "now": "CLOSED — both engines refuse it as input at rc 2 with a byte-identical message. Recorded as a closure, NOT as a standing difference." + }, + { + "id": "V1", + "was": "#262 V1: the reference accepted NaN/Infinity/-Infinity and described a float the source never contained", + "now": "CLOSED — refused at the JSON door on both sides; the residual text difference is CLI-B1, which already existed." + }, + { + "id": "V2-ACCEPTANCE", + "was": "#262 V2: the reference ACCEPTED a top-level -0 as v0 and analysed the document", + "now": "CLOSED as an acceptance divergence; narrowed to CLI-B2 above." + }, + { + "id": "V3", + "was": "#262 V3: oversized integral version values", + "now": "Already closed at #261; VERIFIED not reopened by the parse_int hook the V2 fix installs, at both signs." + }, + { + "id": "PS1-CAPTURE", + "was": "a REGRESSION the cutover introduced in the own-check.ps1 surface, found by the qualification round: PowerShell's call operator routes a child's stdout through its PIPELINE, which is what makes `$out = & ./scripts/own-check.ps1 ...` capture anything. The Python branch uses the call operator; the Rust branch spawned a process that inherited the console handle and so bypassed the pipeline. While Python was the default nobody noticed. The moment Rust became the default, every caller capturing or piping this script's output silently got NOTHING -- the text still appeared on screen, so it looked fine", + "now": "CLOSED. The candidate's streams are redirected and replayed by the caller: stdout to the pipeline, stderr to the error stream, decoded as UTF-8 with no BOM and both pipes drained concurrently so a large SARIF log cannot deadlock. MEASURED after: both engines capture the same single OWN001 line and the captured arrays are IDENTICAL; exit tiers unchanged (1 with -FailOnFinding, 0 without); SARIF is 1413 bytes from either engine, no BOM, parses, one result. Not a declared difference -- an accidental behaviour change beyond the engine, which #262's guardrails forbid, so it was repaired rather than recorded" + } + ], + "observation": { + "mechanism": "the repository's own CI on the candidate branch, plus the release workflow's packed-artifact smoke test", + "what_is_observed": [ + "correctness", + "crashes", + "packaging", + "platform behaviour", + "rollback", + "unexplained differences" + ], + "what_is_NOT_observed": [ + "performance — deferred by owner for this decision; #263 remains the tracker" + ], + "first_run": { + "sha": "b590bf46e9f1c4cbf6bb28978c81afea5ffa1af2", + "result": "7 job failures, every one a real consequence of the cutover, all diagnosed and fixed", + "url": "https://github.com/PhysShell/Own.NET/actions/runs/35302954263" + }, + "qualification_run": { + "sha": "f69f0988a14e00c0925a68b9fddd101aff7b3ebe", + "url": "https://github.com/PhysShell/Own.NET/actions/runs/35307467735", + "result": "30/31 jobs green. The single failure was own-check.ps1's exit-code tiers, which caught the PS1-CAPTURE regression above -- a real defect in the product surface, not a harness fault. Reproduced locally on pwsh (it is not Windows-specific; the Windows job is simply the only place own-check.ps1 is exercised with output capture), fixed, and re-qualified." + } + }, + "stage_3_status": "COMPLETE", + "qualification_points": { + "comment": "Three points, kept apart because they answer three different questions. A commit cannot name its own SHA, so the point that goes to main is recorded outside the tree, on #262, and never inside the commit that creates it.", + "decision": { + "sha": "f1d3681e96e31bba7a36dda09967d0af7c3e9b56", + "run": "https://github.com/PhysShell/Own.NET/actions/runs/35312190310", + "result": "31/31 jobs green, 0 failures, 0 non-success", + "role": "the qualification candidate on which the Stage-3 decision was established; every predicate confirmed on that commit rather than assembled from earlier ones" + }, + "implementation": { + "sha": "b22543680da9e8fa6b2607435bb54a5437815bff", + "run": "https://github.com/PhysShell/Own.NET/actions/runs/35320877147", + "result": "31/31 jobs green, 0 failures, 0 non-success", + "role": "the implementation candidate: the decision tree plus the reconciliation record 7622a3b and the repository-hygiene commit b225436, which untracked the 223 files of Cargo build output under rust/target-fault that 04c3303 had committed by accident (build output is not evidence and is never versioned). No production behaviour changed; the matrix was re-run on the cleaned head because the SHA that goes to main is qualified as itself, not as its ancestor" + }, + "terminal_merge": { + "established_by": "CI on the reconciliation-only head that carries this record -- a documentation commit with no production behaviour change on top of the implementation point", + "canonical_record": "#262 (the body and the Stage-3 record comment) and #250", + "role": "the merge candidate. Its SHA and run are deliberately not written here: a commit cannot name its own SHA, and the external record is authoritative" + } + }, + "defects_found_and_closed_during_qualification": { + "comment": "Recorded here and NOT under known_differences, which is the distinction that matters: after the fixes these are not permitted Stage-3 behaviour. Each was invisible while Python was the default, and each was found by the qualification round rather than by review.", + "items": [ + { + "id": "PS1-CAPTURE", + "what": "own-check.ps1's Rust branch spawned a child that inherited the console handle, bypassing PowerShell's pipeline, so `$out = & ./scripts/own-check.ps1 ...` captured NOTHING while the text still appeared on screen. The Python branch uses the call operator and was unaffected.", + "closed": "streams redirected and replayed -- stdout to the pipeline, stderr to the error stream, UTF-8 with no BOM, both pipes drained concurrently. Measured: both engines now capture the same single OWN001 line, identically; exit tiers unchanged; SARIF 1413 bytes, no BOM, one result from either engine." + }, + { + "id": "PS1-NOTMATCH-ASSERTION", + "what": "the Windows Stage-2 dogfood step asserted `$out -notmatch 'OWN001'`. On an ARRAY PowerShell's -notmatch is a FILTER, not a boolean. SCOPE, stated precisely: it is the Windows Stage-2 dogfood's OWN001 OUTPUT-OBSERVATION assertion that had never constituted evidence on the Rust path -- before the capture repair it received $null, and filtering an empty collection is falsy, so it passed on an empty capture. The other Stage-2 controls and the actual Rust-default execution (exit code, no-fallback, candidate identity) remain separately evidenced; this is NOT a claim that Stage 2 as a whole was unproven.", + "closed": "rewritten as `-not ($out -match 'OWN001')`, the idiom the rest of the file uses. Measured correct in both directions: no throw on the real 166-line capture (137 OWN001 lines), throw on an empty capture. Only instance in any workflow. Confirmed passing on windows-latest at the terminal run." + }, + { + "id": "CI-SURFACES-HOLLOW", + "what": "five CI steps injected a broken or crashing Python and then invoked a launcher BARE; after the cutover a bare invocation never consults OWEN_PYTHON, so the injected fault could not happen and each step would have passed vacuously. Two further call sites lived inside scripts rather than workflow YAML.", + "closed": "each step now names the engine it is actually about, and tests/test_stage3_surfaces.py asserts the property over every workflow AND script -- 63 invocations over 26 files -- with all of its own escape hatches mutation-proved shut." + } + ] + }, + "p037_a1_gate": { + "before": "BLOCKED BY DESIGN — the P-022 freeze on verdict-changing inference held until the cutover completed", + "after": "UNBLOCKED", + "not_started": true, + "comment": "Unblocked by the Stage-3 cutover (decision established at f1d3681, implementation qualified at b225436) and deliberately NOT started in it. A1 begins on a new branch from the Rust-default baseline, so the provenance line stays legible: the last P-022 semantic state, then the Stage-3 terminal commit, then A1. Nobody should have to work out later whether the first ConsumesParam change belonged to the migration or to a new inference feature." + } +} diff --git a/docs/evidence/p022-stage3-sweep.result.json b/docs/evidence/p022-stage3-sweep.result.json new file mode 100644 index 00000000..12467ae6 --- /dev/null +++ b/docs/evidence/p022-stage3-sweep.result.json @@ -0,0 +1,455 @@ +{ + "schema": 1, + "comment": "#262 STAGE 3 RE-QUALIFICATION of the #260 sweep, on the cutover candidate tree. Same frozen definition (docs/evidence/p022-shadow-sweep.json), same pins, same driver, assembled by tests/shadow_sweep.py --collect. It exists BESIDE p022-shadow-sweep.result.json rather than replacing it because they answer different questions: that file is #260's acceptance, anchored to a CI run; this one is whether the same measurement still holds on the tree that moves the public default. It was owed rather than optional -- the #260 record was taken at 321ab8b4, which predates both #261's changes to own-ir's strict door and the Stage-3 hygiene repair to the reference's OwnIR input boundary, so it is not evidence for this candidate however well it validates. workflow_run_url is null and stays null: this run was taken locally, which is a weaker provenance anchor than a CI run and is recorded as such rather than dressed up. The scheduled shadow-sweep workflow re-takes it in CI.", + "sweep": "p022-shadow-sweep", + "definition": "docs/evidence/p022-shadow-sweep.json", + "definition_sha256": "9edfaedd3ba1d1f918cd0f386f33b856f473fa5f2c1cbd4025265ecc06eacd74", + "source_commit": "b590bf46e9f1c4cbf6bb28978c81afea5ffa1af2", + "recorded_at": "2026-09-18T03:28:45Z", + "host": "Linux x86_64", + "workflow_run_url": null, + "driver_version": 2, + "adapters": [ + { + "sha256": "e28fde7a0c1d2313e0c17d6563b33211398e7a6a1646e0ab5f060521746845cb", + "bytes": 1819168 + } + ], + "documents": [ + { + "id": "AvalonEdit.repo", + "source": "AvalonEdit.repo.facts.json", + "target": "AvalonEdit", + "target_commit": "ed0bd149059469ac9bd39b13cf8a341b12a6c1da", + "extraction_mode": "directory-walk", + "extraction_command": "OWN_EXTRA_REF_DIRS= scripts/own-check.sh --format sarif --severity warning --emit-facts -- targets/AvalonEdit", + "facts_sha256": "deae577744ff74afa1c427d9a28304eb7a0f8cfcc9137b7e4422aa64a8f1bdad", + "raw": { + "algorithm": "sha256", + "digest": "deae577744ff74afa1c427d9a28304eb7a0f8cfcc9137b7e4422aa64a8f1bdad", + "bytes": 35279 + }, + "canonical": { + "algorithm": "sha256", + "digest": "0833085ec608b40d14e919beb82fd6984640bda19b1fadab170638c071ac541c", + "bytes": 24006 + }, + "outcome": "agreed", + "timeout_seconds": 600.0, + "reduction_outcome": "identical", + "by_kind": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "missing-layer": 0 + }, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0, + "derived_outcome": "equal", + "wall_clock_seconds": 0.046 + }, + { + "id": "AvalonEdit.sln", + "source": "AvalonEdit.sln.facts.json", + "target": "AvalonEdit", + "target_commit": "ed0bd149059469ac9bd39b13cf8a341b12a6c1da", + "extraction_mode": "solution", + "extraction_command": "OWN_EXTRA_REF_DIRS= scripts/own-check.sh --format sarif --severity warning --emit-facts -- targets/AvalonEdit/ICSharpCode.AvalonEdit.sln", + "facts_sha256": "57fa442681e9fff2e0d38548bd27a6e0fc071fc6931de6b1cef583d8474305b4", + "raw": { + "algorithm": "sha256", + "digest": "57fa442681e9fff2e0d38548bd27a6e0fc071fc6931de6b1cef583d8474305b4", + "bytes": 35279 + }, + "canonical": { + "algorithm": "sha256", + "digest": "7155f02a072bd6b755dbf1d2220c7d2f63fe18531554a873117392bb12cb53fc", + "bytes": 24006 + }, + "outcome": "agreed", + "timeout_seconds": 600.0, + "reduction_outcome": "identical", + "by_kind": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "missing-layer": 0 + }, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0, + "derived_outcome": "equal", + "wall_clock_seconds": 0.05 + }, + { + "id": "ClosedXML.repo", + "source": "ClosedXML.repo.facts.json", + "target": "ClosedXML", + "target_commit": "4e89dcedd83cad553e84d2d97f77fc3d7deb630f", + "extraction_mode": "directory-walk", + "extraction_command": "OWN_EXTRA_REF_DIRS= scripts/own-check.sh --format sarif --severity warning --emit-facts -- targets/ClosedXML", + "facts_sha256": "7b2bf42f4d6f58077e7b01bce2eb873b129dfb024fd3c5284c0b311732a8a4d2", + "raw": { + "algorithm": "sha256", + "digest": "7b2bf42f4d6f58077e7b01bce2eb873b129dfb024fd3c5284c0b311732a8a4d2", + "bytes": 205587 + }, + "canonical": { + "algorithm": "sha256", + "digest": "1b688c50aa1e4df083a791b724d9452a6cf6c03436a50f90e78dfd3f037cb21c", + "bytes": 121095 + }, + "outcome": "agreed", + "timeout_seconds": 600.0, + "reduction_outcome": "identical", + "by_kind": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "missing-layer": 0 + }, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0, + "derived_outcome": "equal", + "wall_clock_seconds": 0.238 + }, + { + "id": "ClosedXML.sln", + "source": "ClosedXML.sln.facts.json", + "target": "ClosedXML", + "target_commit": "4e89dcedd83cad553e84d2d97f77fc3d7deb630f", + "extraction_mode": "solution", + "extraction_command": "OWN_EXTRA_REF_DIRS= scripts/own-check.sh --format sarif --severity warning --emit-facts -- targets/ClosedXML/ClosedXML.sln", + "facts_sha256": "c93b3da2e5c53ca94a1f0f33511300fe83fc81696a0225bb1b3090438d7140e1", + "raw": { + "algorithm": "sha256", + "digest": "c93b3da2e5c53ca94a1f0f33511300fe83fc81696a0225bb1b3090438d7140e1", + "bytes": 205587 + }, + "canonical": { + "algorithm": "sha256", + "digest": "37bd932dfd595d406b9e3b8d1fa23add4aa5f4639447e69233a6ecf161388d95", + "bytes": 121095 + }, + "outcome": "agreed", + "timeout_seconds": 600.0, + "reduction_outcome": "identical", + "by_kind": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "missing-layer": 0 + }, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0, + "derived_outcome": "equal", + "wall_clock_seconds": 0.235 + }, + { + "id": "MahApps.Metro.repo", + "source": "MahApps.Metro.repo.facts.json", + "target": "MahApps.Metro", + "target_commit": "72099e310bac2d12ac98fd7560b69679252519f5", + "extraction_mode": "directory-walk", + "extraction_command": "OWN_EXTRA_REF_DIRS= scripts/own-check.sh --format sarif --severity warning --emit-facts -- targets/MahApps.Metro", + "facts_sha256": "997abb7e2d0d5acb6c4603d420535b7c8aab594b46a20a29f84dfe5175d5be38", + "raw": { + "algorithm": "sha256", + "digest": "997abb7e2d0d5acb6c4603d420535b7c8aab594b46a20a29f84dfe5175d5be38", + "bytes": 44960 + }, + "canonical": { + "algorithm": "sha256", + "digest": "9a6609b7053a74821c9f4d1c60f17cda77cb13af81fbcdf0d02388f7e4bb4c5a", + "bytes": 30399 + }, + "outcome": "agreed", + "timeout_seconds": 600.0, + "reduction_outcome": "identical", + "by_kind": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "missing-layer": 0 + }, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0, + "derived_outcome": "equal", + "wall_clock_seconds": 0.051 + }, + { + "id": "MahApps.Metro.sln", + "source": "MahApps.Metro.sln.facts.json", + "target": "MahApps.Metro", + "target_commit": "72099e310bac2d12ac98fd7560b69679252519f5", + "extraction_mode": "solution", + "extraction_command": "OWN_EXTRA_REF_DIRS= scripts/own-check.sh --format sarif --severity warning --emit-facts -- targets/MahApps.Metro/src/MahApps.Metro.sln", + "facts_sha256": "76baa8e068ee8bf3c4d607d404036d4bb9fb473dde9214f0d87391d7868c7eac", + "raw": { + "algorithm": "sha256", + "digest": "76baa8e068ee8bf3c4d607d404036d4bb9fb473dde9214f0d87391d7868c7eac", + "bytes": 44960 + }, + "canonical": { + "algorithm": "sha256", + "digest": "8a4fff21fa88b5e37b72fa43ab1a43066b0d28e572127c74ff6a4786ae65f707", + "bytes": 30399 + }, + "outcome": "agreed", + "timeout_seconds": 600.0, + "reduction_outcome": "identical", + "by_kind": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "missing-layer": 0 + }, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0, + "derived_outcome": "equal", + "wall_clock_seconds": 0.051 + }, + { + "id": "MaterialDesignInXamlToolkit.repo", + "source": "MaterialDesignInXamlToolkit.repo.facts.json", + "target": "MaterialDesignInXamlToolkit", + "target_commit": "ef3a5ea434e39182b1848f5e11aaea6b3890581f", + "extraction_mode": "directory-walk", + "extraction_command": "OWN_EXTRA_REF_DIRS= scripts/own-check.sh --format sarif --severity warning --emit-facts -- targets/MaterialDesignInXamlToolkit", + "facts_sha256": "e1e8a568d0db5aba411026fc18a882ec5176fe4c9d7acad5cc019f9328d65471", + "raw": { + "algorithm": "sha256", + "digest": "e1e8a568d0db5aba411026fc18a882ec5176fe4c9d7acad5cc019f9328d65471", + "bytes": 54031 + }, + "canonical": { + "algorithm": "sha256", + "digest": "a57afa2bb4247d264d5d83aeb22264eed8a81b4ea11de0f9828fb5c834e68f4c", + "bytes": 38064 + }, + "outcome": "agreed", + "timeout_seconds": 600.0, + "reduction_outcome": "identical", + "by_kind": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "missing-layer": 0 + }, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0, + "derived_outcome": "equal", + "wall_clock_seconds": 0.054 + }, + { + "id": "ShareX.repo", + "source": "ShareX.repo.facts.json", + "target": "ShareX", + "target_commit": "0df9ca4d83eed9d2489048c539d7d1fc2860fdec", + "extraction_mode": "directory-walk", + "extraction_command": "OWN_EXTRA_REF_DIRS= scripts/own-check.sh --format sarif --severity warning --emit-facts -- targets/ShareX", + "facts_sha256": "078e4df63f8da207803bfab938acc49d8b8ffec145748d6447f38d9423dc1c11", + "raw": { + "algorithm": "sha256", + "digest": "078e4df63f8da207803bfab938acc49d8b8ffec145748d6447f38d9423dc1c11", + "bytes": 248611 + }, + "canonical": { + "algorithm": "sha256", + "digest": "3966d3d8f736d587e07a7e9c85de5a109fac52ea53c1b9f22311662333fe457b", + "bytes": 136590 + }, + "outcome": "agreed", + "timeout_seconds": 600.0, + "reduction_outcome": "identical", + "by_kind": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "missing-layer": 0 + }, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0, + "derived_outcome": "equal", + "wall_clock_seconds": 0.223 + }, + { + "id": "ShareX.sln", + "source": "ShareX.sln.facts.json", + "target": "ShareX", + "target_commit": "0df9ca4d83eed9d2489048c539d7d1fc2860fdec", + "extraction_mode": "solution", + "extraction_command": "OWN_EXTRA_REF_DIRS= scripts/own-check.sh --format sarif --severity warning --emit-facts -- targets/ShareX/ShareX.sln", + "facts_sha256": "62732f41579911d710feec0e2ec228721fe6090eab99422571e5a489dbffa233", + "raw": { + "algorithm": "sha256", + "digest": "62732f41579911d710feec0e2ec228721fe6090eab99422571e5a489dbffa233", + "bytes": 248611 + }, + "canonical": { + "algorithm": "sha256", + "digest": "a00bbe481dc23b4f21d0367d95876321eba8d2c5f94e4f21ab53d4e952259781", + "bytes": 136590 + }, + "outcome": "agreed", + "timeout_seconds": 600.0, + "reduction_outcome": "identical", + "by_kind": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "missing-layer": 0 + }, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0, + "derived_outcome": "equal", + "wall_clock_seconds": 0.224 + }, + { + "id": "examples", + "source": "examples.facts.json", + "target": "examples", + "target_commit": "b590bf46e9f1c4cbf6bb28978c81afea5ffa1af2", + "extraction_mode": "directory-walk", + "extraction_command": "scripts/own-check.sh --format sarif --severity warning --emit-facts -- examples", + "facts_sha256": "b53e82ab254407d65a5e6533b1bd18af7c6bcbf19fb89464b8917adc80c4093a", + "raw": { + "algorithm": "sha256", + "digest": "b53e82ab254407d65a5e6533b1bd18af7c6bcbf19fb89464b8917adc80c4093a", + "bytes": 12721 + }, + "canonical": { + "algorithm": "sha256", + "digest": "4345a12f5b1e10bb0e7600c272070eff541b893bb24dd3bf095c236be9a2dc0a", + "bytes": 6705 + }, + "outcome": "agreed", + "timeout_seconds": 600.0, + "reduction_outcome": "identical", + "by_kind": { + "left-only": 0, + "right-only": 0, + "changed": 0, + "ordering-only": 0, + "status": 0, + "projection": 0, + "missing-layer": 0 + }, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0, + "derived_outcome": "equal", + "wall_clock_seconds": 0.03 + } + ], + "targets": [ + { + "target": "AvalonEdit", + "documents_extracted": 2, + "compare_attempted": 2, + "agreed": 2, + "diverged": 0, + "execution_failures": 0, + "input_refusals": 0, + "input_disagreements": 0, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0 + }, + { + "target": "ClosedXML", + "documents_extracted": 2, + "compare_attempted": 2, + "agreed": 2, + "diverged": 0, + "execution_failures": 0, + "input_refusals": 0, + "input_disagreements": 0, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0 + }, + { + "target": "MahApps.Metro", + "documents_extracted": 2, + "compare_attempted": 2, + "agreed": 2, + "diverged": 0, + "execution_failures": 0, + "input_refusals": 0, + "input_disagreements": 0, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0 + }, + { + "target": "MaterialDesignInXamlToolkit", + "documents_extracted": 1, + "compare_attempted": 1, + "agreed": 1, + "diverged": 0, + "execution_failures": 0, + "input_refusals": 0, + "input_disagreements": 0, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0 + }, + { + "target": "ShareX", + "documents_extracted": 2, + "compare_attempted": 2, + "agreed": 2, + "diverged": 0, + "execution_failures": 0, + "input_refusals": 0, + "input_disagreements": 0, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0 + }, + { + "target": "examples", + "documents_extracted": 1, + "compare_attempted": 1, + "agreed": 1, + "diverged": 0, + "execution_failures": 0, + "input_refusals": 0, + "input_disagreements": 0, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0 + } + ], + "totals": { + "documents_extracted": 10, + "compare_attempted": 10, + "agreed": 10, + "diverged": 0, + "execution_failures": 0, + "input_refusals": 0, + "input_disagreements": 0, + "declared_boundary_observations": 0, + "acceptance_unexplained_observations": 0 + } +} diff --git a/docs/generated/p022-cli-census.md b/docs/generated/p022-cli-census.md index b6818dd6..e979b612 100644 --- a/docs/generated/p022-cli-census.md +++ b/docs/generated/p022-cli-census.md @@ -6,7 +6,7 @@ | measure | value | |------------------------------------|------:| -| frozen cases | 88 | +| frozen cases | 94 | ## By oracle @@ -15,7 +15,7 @@ The oracle boundary is #261's C-1, and it is the SURFACE rather than the referen | oracle | cases | what authored the bytes | |---|------:|---| | `owen-convention` | 7 | no Python byte oracle exists: the top-level shell, authored once from the `owen` convention and shared with the binary | -| `python` | 77 | an executed `python -m ownlang ownir` run | +| `python` | 83 | an executed `python -m ownlang ownir` run | | `python-docstring` | 4 | the same, where the bytes are the WHOLE module docstring on stdout — frozen as measured and flagged, so the owner can declare that class a defect knowing what was frozen | ## By rule @@ -25,7 +25,7 @@ A case may be the control for more than one rule, so these do not sum to the cas | rule | cases | |---|------:| | `advisory-never-fails-the-run` | 3 | -| `cli-b1-json-parser-detail` | 3 | +| `cli-b1-json-parser-detail` | 6 | | `cli-b1-negative-control` | 1 | | `debug-env-is-inert-when-nothing-crashes` | 1 | | `declared-defect` | 1 | @@ -50,10 +50,13 @@ A case may be the control for more than one rule, so these do not sum to the cas | `sarif-carries-shown-plus-suppressed` | 7 | | `stdin-out-of-contract` | 1 | | `stream-split` | 36 | -| `strict-door` | 12 | +| `strict-door` | 18 | | `summary-suppressed-tail` | 26 | | `unknown-flag-is-positional` | 2 | | `usage-owen-shape` | 7 | +| `utf8-byte-parity` | 1 | +| `v1-non-standard-constants` | 3 | +| `v2-top-level-negative-zero` | 2 | | `verbose-counts-every-finding` | 13 | -| `version-byte-parity` | 5 | +| `version-byte-parity` | 6 | | `windows-path-form` | 2 | diff --git a/docs/generated/p022-cli-mutations.md b/docs/generated/p022-cli-mutations.md index ec11e8cf..f82cade1 100644 --- a/docs/generated/p022-cli-mutations.md +++ b/docs/generated/p022-cli-mutations.md @@ -8,11 +8,11 @@ The production OwnIR executable's contract: the display policy the reference's ` Campaign `p022-cli-1` — #261 261.B, the production OwnIR executable `own-cli ownir`: the display policy the reference's cmd_ownir defines (which findings are shown, the summary and `ok` lines, the verbosity variants, the stream split), the CLI's own SARIF serialization conventions (ASCII escaping, and the suppressed findings that ride in the results), the usage-error exit codes, and the process contract (a catchable panic is one diagnostic and exit 70, never 101). Every mutation is a plausible misreading of the reference rather than a syntactic accident: each one would pass a reviewer who had read the docstring instead of the code. The repair pass (#261 R2/R3) adds the strict-door families: the Version messages, which ruling 2a FIXED to byte parity rather than declaring, and the CLI-B1 boundary, whose CLI-owned wrapper is pinned and whose guard must fail onto rc 70 rather than swallow its own structural drift as the declared tail. The second repair pass (#261 R2b/R3b) adds the classes those families' first measurement could not reach: CPython's dict ORDER and its float SPELLING, the arbitrary-precision integer that changes which branch the reference takes, the round-half-to-even tie in the shortest round-trip digits, and the CLI-B1 negative control's own load-bearingness. -Definition: `docs/evidence/p022-cli-1.json` (sha256 `f86d83855bd7cd04…`, 24 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-cli-1.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. +Definition: `docs/evidence/p022-cli-1.json` (sha256 `94afbda36698263d…`, 24 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-cli-1.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. | measure | value | |--------------------------------------------------|---| -| recorded at commit | `f932374ed8a5fc4731c37c3ce1cf5a071c1843a5` | +| recorded at commit | `b5d9272a0a858cb7fd80e91b3d72bce6b3fb5e84` | | layers run (every one, for every mutation) | `own-cli`, `own-cli-faults`, `own-ir`, `rust-rest` | | mutations | 24 | | caught | 24 | @@ -46,6 +46,6 @@ Definition: `docs/evidence/p022-cli-1.json` (sha256 `f86d83855bd7cd04…`, 24 mu | M19 | cli-b1-json-parser-detail | the boundary guard swallows its own structural drift: a Json rejection that lost `own-ir`'s internal prefix is passed through as rc 2 instead of failing onto the internal-error path — the lock left out of the door | caught | `own-cli/src/main.rs::ownir::tests::a_json_rejection_that_lost_its_prefix_is_an_internal_error_not_rc2` | | M20 | version-byte-parity | a repeated dict key is APPENDED instead of rebound in place, so it appears twice and in the wrong position — `{'b': 3, 'a': 2}` becomes `{'b': 1, 'a': 2, 'b': 3}`. CPython's decoder rebinds; the position comes from the first binding and the value from the last | caught | `own-ir/src/lib.rs::pyrepr::tests::a_repeated_key_is_rebound_in_place`
`own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | | M21 | version-byte-parity | the float exponent loses CPython's zero padding — `1e-06` becomes `1e-6` and `1e+16` becomes `1e16`. This is the exact spelling R2 shipped: the four hand-picked controls it measured had no exponent, so nothing saw it | caught | `own-ir/src/lib.rs::pyrepr::tests::an_oversized_integer_keeps_its_digits`
`own-ir/src/lib.rs::pyrepr::tests::matches_cpython_float_repr`
`own-ir/src/lib.rs::pyrepr::tests::the_exponent_padding_is_the_defect_r2_left`
`own-ir/src/lib.rs::pyrepr::tests::the_literal_decides_int_or_float`
`own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | -| M22 | version-byte-parity | the gate stops re-reading the raw document and spells the rejection from the parsed `Value` alone — object keys come back sorted and an oversized integer takes the wrong-type arm as `1e+31` instead of the reference's mismatch arm. One mutation, because one line is what the whole raw re-read hangs on | caught | `own-ir/tests/version_repr_census.rs::negative_zero_below_the_top_level_matches_the_reference`
`own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | +| M22 | version-byte-parity | the gate stops re-reading the raw document and spells the rejection from the parsed `Value` alone — object keys come back sorted and an oversized integer takes the wrong-type arm as `1e+31` instead of the reference's mismatch arm. One mutation, because one line is what the whole raw re-read hangs on | caught | `own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte`
`own-ir/tests/version_repr_census.rs::negative_zero_below_the_top_level_matches_the_reference`
`own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | | M23 | version-byte-parity | the float digits come from Rust's SHORTEST formatter rather than its exact formatter at the shortest length, so a value exactly midway between two candidates rounds away from CPython's round-half-to-even — `-1128910513108089.2` becomes `-1128910513108089.3`. Reachable by roughly one double in 3 000 and by none of the controls chosen by hand | caught | `own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | -| M24 | cli-b1-json-parser-detail | the CLI-B1 eligibility guard judges a file it reaches for instead of the bytes it was handed, so the negative control's two runs stop differing. This mutates the EVIDENCE, not the product, and it is here for one reason: #261 R3b replaced a negative control that read a different case at a different path, and a control held to one argv and one path is only worth more than that one if it actually fires when the guard stops reading the supplied bytes. Non-vacuity itself is a property of the control's construction — one case, two byte sequences — and is not mutation-provable; this shows the construction is load-bearing | caught | `own-cli/tests/replay.rs::cli_b1_flips_on_the_facts_bytes_and_nothing_else` | +| M24 | cli-b1-json-parser-detail | the CLI-B1 eligibility guard judges a file it reaches for instead of the bytes it was handed, so the negative control's two runs stop differing. This mutates the EVIDENCE, not the product, and it is here for one reason: #261 R3b replaced a negative control that read a different case at a different path, and a control held to one argv and one path is only worth more than that one if it actually fires when the guard stops reading the supplied bytes. Non-vacuity itself is a property of the control's construction — one case, two byte sequences — and is not mutation-provable; this shows the construction is load-bearing | caught | `own-cli/tests/replay.rs::cli_b1_flips_on_the_facts_bytes_and_nothing_else`
`own-cli/tests/replay.rs::cli_b2_does_not_reach_a_nested_negative_zero`
`own-cli/tests/replay.rs::every_declared_boundary_case_is_eligible_for_the_boundary_it_names`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | diff --git a/docs/generated/p022-coord-census.md b/docs/generated/p022-coord-census.md index a464d5c1..3f4a526a 100644 --- a/docs/generated/p022-coord-census.md +++ b/docs/generated/p022-coord-census.md @@ -10,7 +10,7 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | measure | value | |------------------------------------|------:| -| JSON files scanned | 430 | +| JSON files scanned | 438 | | coordinate slots found | 2197 | ## By value class diff --git a/docs/generated/p022-stage1-mutations.md b/docs/generated/p022-stage1-mutations.md index a6a87f02..4db900aa 100644 --- a/docs/generated/p022-stage1-mutations.md +++ b/docs/generated/p022-stage1-mutations.md @@ -8,11 +8,11 @@ Stage 1 makes the Rust core SELECTABLE by the launcher while Python stays the de Campaign `p022-stage1-1` — #262 Stage 1 — the launcher's engine-selection contract: that Python stays the default, that an explicitly selected Rust core actually runs (and needs no Python), that a Rust failure is never a Python success, that an unexpected child status becomes public exit 5 with the raw status retained, that an unusable OWEN_RUST_CORE is a configuration error rather than a fallback, and that compare extracts once, feeds both engines the same bytes, and refuses to answer when they disagree or when either fails. Every mutation is a plausible MISREADING of that contract rather than a syntactic accident: each one would pass a reviewer who had read the stage's summary instead of its rulings. Every mutation edits a PRODUCTION launcher surface, and every declared layer runs for every mutation (no fail-fast). -Definition: `docs/evidence/p022-stage1-1.json` (sha256 `c8bd2180f285a130…`, 21 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage1-1.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. +Definition: `docs/evidence/p022-stage1-1.json` (sha256 `076165178704095f…`, 21 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage1-1.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. | measure | value | |--------------------------------------------------|---| -| recorded at commit | `da897b42fcd76ea1914d286bd6bae6065c072c70` | +| recorded at commit | `f988c8e1be7ff12a99b46d5bff444c68d00b8743` | | layers run (every one, for every mutation) | `stage1` | | mutations | 21 | | caught | 21 | @@ -25,13 +25,13 @@ Definition: `docs/evidence/p022-stage1-1.json` (sha256 `c8bd2180f285a130…`, 21 | id | rule | mutation | outcome | caught by | |---|---|---|---|---| -| M01 | default-is-python | the Stage-1 default engine is Rust — the cutover read as already decided, instead of Python remaining default until Gate G3 | caught | `stage1::default-stays-python` | -| M02 | rust-run-needs-no-python | Python is resolved for every engine — the old unconditional resolution kept 'just in case', which silently re-imposes a Python dependency on a Rust-only run | caught | `stage1::rust-actually-runs-rust` | -| M03 | 70-is-not-a-verdict | 70 is treated as a legal engine result — the shared internal-error code mistaken for part of the verdict contract because both engines document it | caught | `stage1::rc70-is-not-a-verdict`
`stage1::rust-failure-no-fallback` | -| M04 | unexpected-rc-maps-to-5 | an unexpected Rust child status passes through as itself — 'propagate the child's exit code' read as faithfulness rather than as leaking a meaningless number to the caller | caught | `stage1::raw-rc-retained`
`stage1::rc70-is-not-a-verdict`
`stage1::rust-failure-no-fallback`
`stage1::unexpected-rc-maps-to-5` | +| M01 | default-is-python | the public default engine goes back to Python -- the cutover silently REVERSED. Until #262 Stage 3 this mutant ran the other way (Python -> Rust, 'the covert Stage 3'); the cutover made that the correct state, so the defect it guards against turned over with it. What the mutant is FOR did not change: one edit to one constant moves what every user gets | caught | `stage1::default-is-rust`
`stage1::unset-locator-is-d6` | +| M02 | rust-run-needs-no-python | Python is resolved for every engine — the old unconditional resolution kept 'just in case', which silently re-imposes a Python dependency on a Rust-only run | caught | `stage1::default-is-rust`
`stage1::rust-actually-runs-rust` | +| M03 | 70-is-not-a-verdict | 70 is treated as a legal engine result — the shared internal-error code mistaken for part of the verdict contract because both engines document it | caught | `stage1::default-is-rust`
`stage1::rc70-is-not-a-verdict`
`stage1::rust-failure-no-fallback` | +| M04 | unexpected-rc-maps-to-5 | an unexpected Rust child status passes through as itself — 'propagate the child's exit code' read as faithfulness rather than as leaking a meaningless number to the caller | caught | `stage1::default-is-rust`
`stage1::raw-rc-retained`
`stage1::rc70-is-not-a-verdict`
`stage1::rust-failure-no-fallback`
`stage1::unexpected-rc-maps-to-5` | | M05 | raw-rc-retained | the raw child status is dropped from the report — the human-readable cause already names the number, so the typed carrier looks redundant | caught | `stage1::raw-rc-retained` | -| M06 | bad-locator-is-2-not-5 | an unusable OWEN_RUST_CORE is an internal error — a failure to start the engine read as Owen's own bug rather than the caller's configuration | caught | `stage1::absolute-locator-only`
`stage1::bad-locator-is-2` | -| M07 | bad-locator-is-2-not-3 | an unusable OWEN_RUST_CORE reuses exit 3 — 'no usable engine runtime' read as the same class as 'no usable Python', which it is not | caught | `stage1::absolute-locator-only`
`stage1::bad-locator-is-2` | +| M06 | bad-locator-is-2-not-5 | an unusable OWEN_RUST_CORE is an internal error — a failure to start the engine read as Owen's own bug rather than the caller's configuration | caught | `stage1::absolute-locator-only`
`stage1::bad-locator-is-2`
`stage1::unset-locator-is-d6` | +| M07 | bad-locator-is-2-not-3 | an unusable OWEN_RUST_CORE reuses exit 3 — 'no usable engine runtime' read as the same class as 'no usable Python', which it is not | caught | `stage1::absolute-locator-only`
`stage1::bad-locator-is-2`
`stage1::unset-locator-is-d6` | | M08 | no-fallback-in-the-shell | the shell falls back to Python when the Rust core produces no verdict — 'be helpful, still give the user an answer', which is precisely the silent fallback every ruling forbids | caught | `stage1::rust-failure-no-fallback` | | M09 | shell-locator-is-2-not-3 | the shell reports an unusable OWEN_RUST_CORE as exit 3 — the Python-specific 'no usable runtime' code borrowed for the Rust candidate | caught | `stage1::absolute-locator-only`
`stage1::bad-locator-is-2` | | M10 | divergence-is-5 | 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 | caught | `stage1::compare-no-substitution`
`stage1::compare-same-input`
`stage1::divergence-is-5` | @@ -45,17 +45,17 @@ Definition: `docs/evidence/p022-stage1-1.json` (sha256 `c8bd2180f285a130…`, 21 | M18 | shell-locator-must-be-absolute | 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 | caught | `stage1::absolute-locator-only`
`stage1::locator-shapes` | | M19 | compare-verdict-is-stated | 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' | caught | `stage1::compare-failure-classified` | | M20 | absolute-locator-is-accepted | the shell's POSIX arm stops recognising an absolute path — the over-rejection direction of D3, where a validator that refuses EVERYTHING passes every 'reject the relative one' assertion and makes the tool unusable with a correct configuration | caught | `stage1::compare-extracts-once`
`stage1::compare-same-input`
`stage1::compare-zero-document`
`stage1::divergence-is-5`
`stage1::exec-failure-is-5`
`stage1::locator-shapes`
`stage1::rust-failure-no-fallback` | -| M21 | absolute-locator-is-accepted | the launcher's absoluteness test is inverted — the same over-rejection direction in the C# implementation: every fully qualified locator is refused as 'not absolute' and every relative one is admitted | caught | `stage1::absolute-locator-only`
`stage1::candidate-identity`
`stage1::compare-failure-classified`
`stage1::compare-same-input`
`stage1::compare-zero-document`
`stage1::divergence-is-5`
`stage1::exec-failure-is-5`
`stage1::locator-shapes`
`stage1::raw-rc-retained`
`stage1::rc70-is-not-a-verdict`
`stage1::rust-actually-runs-rust`
`stage1::rust-failure-no-fallback`
`stage1::unexpected-rc-maps-to-5` | +| M21 | absolute-locator-is-accepted | the launcher's absoluteness test is inverted — the same over-rejection direction in the C# implementation: every fully qualified locator is refused as 'not absolute' and every relative one is admitted | caught | `stage1::absolute-locator-only`
`stage1::candidate-identity`
`stage1::compare-failure-classified`
`stage1::compare-same-input`
`stage1::compare-zero-document`
`stage1::default-is-rust`
`stage1::divergence-is-5`
`stage1::exec-failure-is-5`
`stage1::locator-shapes`
`stage1::raw-rc-retained`
`stage1::rc70-is-not-a-verdict`
`stage1::rust-actually-runs-rust`
`stage1::rust-failure-no-fallback`
`stage1::unexpected-rc-maps-to-5` | ## Stage 1 — the surfaces only Windows can be asked about: `own-check.ps1`, and the drive-rooted arm of the shell's locator classifier. Measured on a WINDOWS runner, because a mutant of either is invisible to a Linux catcher Campaign `p022-stage1-windows` — #262 Stage 1 — the surfaces whose contract only Windows can be asked about. `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. Beside it sits the Windows half of `scripts/own-check.sh`'s locator classifier, which the Linux campaign cannot reach at all — a drive-rooted path is absolute only where Windows resolves it, and a mutant of that arm is invisible on Linux whatever the runner reports. It also carries the spawn seam itself: a candidate must be STARTED, never handed to the platform to open, and the two desktop handlers that proved this — notepad and xdg-open — are why the seam was unreachable on either platform until it was fixed. Every mutation here is a plausible MISREADING rather than a syntactic accident. -Definition: `docs/evidence/p022-stage1-windows.json` (sha256 `6dbfa6054f6447bb…`, 9 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage1-windows.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. +Definition: `docs/evidence/p022-stage1-windows.json` (sha256 `656c5e69e4ec0167…`, 9 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage1-windows.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. | measure | value | |--------------------------------------------------|---| -| recorded at commit | `da897b42fcd76ea1914d286bd6bae6065c072c70` | +| recorded at commit | `baf3771cd0106bf9a2242261c203445cf24f85c7` | | layers run (every one, for every mutation) | `ps1`, `shapes` | | mutations | 9 | | caught | 9 | @@ -72,8 +72,8 @@ Definition: `docs/evidence/p022-stage1-windows.json` (sha256 `6dbfa6054f6447bb | P02 | ps1-not-started-is-configuration | 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 | caught | `ps1::ps1-not-started-is-2` | | P03 | ps1-agreement-replays-raw-bytes | own-check.ps1 replays STDOUT through `Get-Content -Raw | Write-Output` — a decode-and-re-encode read as an echo. Scoped to stdout ONLY: it leaves the stderr replay intact, so it can be killed by nothing but the stdout half of the byte-faithfulness assertion. The earlier version of this mutation also emptied $errBytes, which meant its death proved only that stdout was checked and left 'both streams' as grammar | caught | `ps1::ps1-agreement-replays` | | P04 | ps1-failure-evidence-survives | 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 | caught | `ps1::ps1-failure-evidence` | -| P05 | absolute-locator-is-accepted | the shell's drive-rooted arm stops matching — this is the defect that actually shipped: `[/\]` escapes the closing bracket, so the set is unterminated and matches NEITHER `C:/` nor `C:\`, and every correct Windows locator was refused as 'not absolute' while every Linux control stayed green | caught | `shapes::locator-shapes` | +| P05 | absolute-locator-is-accepted | the shell's drive-rooted arm stops matching — this is the defect that actually shipped: `[/\]` escapes the closing bracket, so the set is unterminated and matches NEITHER `C:/` nor `C:\`, and every correct Windows locator was refused as 'not absolute' while every Linux control stayed green | caught | `ps1::ps1-agreement-replays`
`shapes::locator-shapes` | | P06 | absolute-locator-is-accepted | own-check.ps1's absoluteness test is inverted — the over-rejection direction on this surface: every fully qualified locator is refused as 'not absolute' and every relative one is admitted, which no assertion that only feeds it a relative path can see | caught | `ps1::ps1-absolute-locator`
`ps1::ps1-agreement-replays`
`ps1::ps1-failure-evidence`
`ps1::ps1-not-started-is-2` | -| P07 | the-candidate-is-spawned-not-opened | own-check.ps1 goes back to invoking the candidate with the call operator — 'PowerShell runs it either way, why the ceremony?'. It does not run it: it asks the platform to OPEN it, so a file the loader cannot start is handed to a desktop handler (notepad on Windows, xdg-open on Linux), the run exits 0 with empty streams, and Owen reports a clean finding-free analysis of nothing | caught | `ps1::ps1-not-started-is-2` | +| P07 | the-candidate-is-spawned-not-opened | own-check.ps1 goes back to invoking the candidate with the call operator -- 'PowerShell runs it either way, why the ceremony?'. It does not run it: it asks the platform to OPEN it, so a file the loader cannot start is handed to a desktop handler (notepad on Windows, xdg-open on Linux), the run exits 0 with empty streams, and own-check.ps1 reports a clean finding-free scan having analysed nothing. Re-anchored at #262 Stage 3, when this function grew stream redirection: the candidate's output has to reach PowerShell's PIPELINE or a caller capturing this script gets nothing, so the spawn now returns a result object. The defect the mutant describes is unchanged -- only the lines it replaces moved. | caught | `ps1::ps1-not-started-is-2` | | P08 | ps1-agreement-replays-raw-bytes | own-check.ps1 drops the STDERR half of the agreement replay while leaving stdout byte-faithful — 'stderr is diagnostics, the result is stdout', which is how the original defect was written in the first place. It is the twin of P03 and exists because a single mutation that broke both streams could be killed by the stdout assertion alone: with this one, the stderr assertion is the only thing standing between the mutant and a green run | caught | `ps1::ps1-agreement-replays` | | P09 | the-locator-is-a-preflight | own-check.ps1 checks that the .NET toolchain answers before it validates the locator — 'fail on the missing tool first', which plenty of CLIs do. The rejection it then produces is still correct in every observable way: exit 2, the absolute requirement named, no verdict. What it loses is the POSITION: a decision reachable from one environment variable now happens behind an external process, and on the real path that process is a Roslyn extraction over the caller's whole tree. Nothing in this control caught that until it began counting child processes | caught | `ps1::ps1-absolute-locator` | diff --git a/docs/generated/p022-stage2-mutations.md b/docs/generated/p022-stage2-mutations.md index f3ff60d8..6e4a3ad4 100644 --- a/docs/generated/p022-stage2-mutations.md +++ b/docs/generated/p022-stage2-mutations.md @@ -8,11 +8,11 @@ Stage 2 moves nothing a user can see: it makes THIS repository's CI and dogfood Campaign `p022-stage2-1` — #262 Stage 2 — the CI/dogfood ENGINE-SELECTION seam. Stage 2's deliverable is a configuration, so its mutants are configuration: each one is a plausible way the Rust-default claim could quietly stop being true, or the public contract could quietly start moving, while every job still went green. The catchers are the controls in tests/test_stage2_dogfood.py, which read the workflows rather than being told about them. -Definition: `docs/evidence/p022-stage2-1.json` (sha256 `fd96f37ba882a9b9…`, 11 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage2-1.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. +Definition: `docs/evidence/p022-stage2-1.json` (sha256 `51118fd9c55f5825…`, 11 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage2-1.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. | measure | value | |--------------------------------------------------|---| -| recorded at commit | `345cf04fe9f9880a2b316f220b565412e9cb9855` | +| recorded at commit | `0d6686eddc91613385ce39bccf3f581e9d7a7f05` | | layers run (every one, for every mutation) | `stage2` | | mutations | 11 | | caught | 11 | @@ -30,8 +30,8 @@ Definition: `docs/evidence/p022-stage2-1.json` (sha256 `fd96f37ba882a9b9…`, 11 | S03 | the-claim-covers-both-platforms | the Rust-default dogfood matrix loses its Windows leg — 'our CI runs on Rust' quietly becomes a statement about Linux, which is the half of the product whose launcher mechanics never broke | caught | `stage2::platform-leg-lost` | | S04 | the-locator-is-owen-rust-core-alone | the dogfood finds its candidate on PATH — 'the binary is right there, why spell out an absolute path', which is precisely how a stale binary stands in for the one under test | caught | `stage2::locator-contract-bypassed` | | S05 | a-rust-failure-is-visible | the dogfood run swallows its exit code — the classic 'don't let the dogfood job break the build', which turns every Rust-default claim in this PR into decoration | caught | `stage2::rust-job-falls-back` | -| S06 | the-public-default-does-not-move | the PRODUCT default flips to Rust — the covert Stage 3, and the single edit that would make every internal Rust job pass by accident while changing what every user gets | caught | `stage2::public-default-moved` | -| S07 | the-public-default-does-not-move | the ACTION's public engine input defaults to rust — the same cutover through the other public door, and the one a C#-only control would miss | caught | `stage2::public-default-moved` | +| S06 | the-public-default-does-not-move | the PRODUCT default goes back to Python -- the cutover silently reversed through the C# launcher. Inverted at #262 Stage 3: it used to mutate Python -> Rust, which is now the shipped state | caught | `stage2::public-default-is-rust` | +| S07 | the-public-default-does-not-move | the ACTION's public engine input goes back to python -- the same reversal through the other public door, and the one a C#-only control would miss. Inverted at #262 Stage 3 for the same reason as S06 | caught | `stage2::public-default-is-rust` | | S08 | compare-evidence-is-not-traded-away | a #260 compare gate is disabled — the cheapest way to make a Rust-default dogfood green is to remove the job that would have disagreed with it | caught | `stage2::compare-gate-dropped` | | S09 | no-unclassified-call-site | a new bare launcher invocation appears in a job nobody classified — the hole the census exists to close, and the one that reopens every time somebody adds a convenient scan step | caught | `stage2::stage2-census` | | S10 | no-escape-by-relabelling | the dog-food job is reclassified out of the Rust-default population — every other rule is satisfied by shrinking Class D, so without a rule that reads the job's own name this is a green way to stop dogfooding | caught | `stage2::stage2-census` | diff --git a/docs/generated/p022-stage3-packet.md b/docs/generated/p022-stage3-packet.md new file mode 100644 index 00000000..6ee3b9fb --- /dev/null +++ b/docs/generated/p022-stage3-packet.md @@ -0,0 +1,70 @@ + +# P-022 Stage 3 — #262 cutover decision packet + +```text +Stage-3 candidate SHA: b22543680da9e8fa6b2607435bb54a5437815bff (DIRTY TREE — not evidence) +Observation window: the repository's own CI on the candidate branch, plus the release workflow's packed-artifact smoke test; first run b590bf46e9f1: 7 job failures, every one a real consequence of the cutover, all diagnosed and fixed +Fast compare result: 104 documents, 104 agreed, 0 acceptance-unexplained | samples: 1 document, 1 agreed, 0 acceptance-unexplained +Five-repo compare result: 10/10 documents agreed over 6 targets; 0 acceptance-unexplained, 0 declared-boundary; at b590bf46e9f1; local run (no CI anchor) +Large-solution result: the largest .sln of every target that has one is inside the same run (4 solution documents); 0 diverged, 0 execution-failure +Windows packaging result: PASS. The job concluded success, and it carries the unconditional Stage-3 assertion that a bare `owen check` with a deliberately unusable OWEN_PYTHON still finds OWN001 at rc 1 and never consults the development locator -- under `bash -e` with an explicit `exit 1`, so a success conclusion is that step passing. Nothing here is carried over from the Linux row: this is windows-latest reporting on itself. Its install -> check -> findings, clean-code, uninstall/reinstall and vendored-cache steps passed in the same job +Linux packaging result: bare `owen check` -> OWN001, rc 1, no Python present; --engine python -> same finding; packaged binary chmod 644 -> materialised to ~/.owen/rust-core/ and ran; packaged binary absent -> rc 2 denying a fallback +Startup delta: DEFERRED BY OWNER — NOT MEASURED +End-to-end delta: DEFERRED BY OWNER — NOT MEASURED +Peak memory delta: DEFERRED BY OWNER — NOT MEASURED +Known differences: WIN-ABC (carried forward from #262); CLI-B1 (carried forward); CLI-B2 (NEW); V4 (carried forward); CANCEL-TRACEBACK (NEW); ACTION-BUILD (OWNER RULING: ACCEPTED FOR STAGE 3 — a declared temporary distribution cost) +Rollback command/config: `--engine python` / `-Engine python` / `engine: python`; see `docs/notes/owen-engine-rollback.md` +Python-removal timing: Stage 4 — a separate, separately reviewable PR, after the observation policy. NOT now. +``` + +> **Performance.** Performance evidence is deferred by owner for the Stage-3 public-default decision. Stage 3 makes no performance claim. #263 remains required before any published/reproducible performance claim and remains the performance-baseline tracker. + +CLI contract campaign: 24/24 mutants caught at b5d9272a0a85 + +## Known differences + +* **WIN-ABC** — _carried forward from #262, unchanged_ + Windows canonical UTF-8. A: canonical reference parity CLAIMED. B: Rust portability CLAIMED. C: native-Windows Python parity NOT CLAIMED — the reference emits cp1252/CRLF there and can fail with UnicodeEncodeError, so a Windows user's bytes change at cutover. This is a DECLARED BEHAVIOR CHANGE, not a regression, and it bit a Stage-3 control that had compared raw bytes across engines on Windows; that control now compares the verdict. +* **CLI-B1** — _carried forward, now 6 cases_ + JSON_PARSER_DETAIL. Applies iff the strict door's kind is Json. The full CLI-owned wrapper is pinned byte-exact; only the parser library's own text after it is declared. +* **CLI-B2** — _NEW, and a NARROWING of #262's V2_ + TOP_LEVEL_NEGATIVE_ZERO. Was an ACCEPT-versus-REJECT divergence: the reference read a top-level `-0` as the integer 0 and analysed the document as v0, while serde_json refused it. The Python-first hygiene fix makes both sides refuse; what is left declared is how each spells the token it refused (`-0` vs `-0.0`), at the same exit code and the same door. Reconciling it would mean teaching one parser the other's reading of `-0`, which #262 ruled out and #260 froze a refusal for. +* **V4** — _carried forward, unchanged, reopen predicate verified NOT fired_ + str.isprintable() is answered from a version-dependent Unicode table. A representation/diagnostic boundary only, excluded from the byte-parity denominator and separately measured. Verified on this tree: the supported Python matrix is still 3.11/3.12/3.13 and the Rust snapshot is still unicode-properties 0.1.4. +* **CANCEL-TRACEBACK** — _NEW, measured, and LINUX-ONLY_ + On LINUX an interrupted Python reference prints a KeyboardInterrupt traceback (1478 bytes measured) while the Rust core prints nothing, so a cancelled run stops printing a stack trace after the cutover. On WINDOWS neither engine prints anything (stderr 0b for both): the default CTRL_BREAK handler terminates the process before Python's handler runs. Measured on each platform rather than carried across -- the difference exists on one of them and not the other, which a single-platform measurement would have got wrong in either direction. User-visible, caused by the cutover, recorded rather than fixed: Stage 3 changes the default engine, not the reference. +* **ACTION-BUILD** — _OWNER RULING: ACCEPTED FOR STAGE 3 — a declared temporary distribution cost_ + The Owen Action builds the production `own-cli` from its own pinned ref, because this repository publishes no release: there is no own-cli artifact to download and no published Owen.Cli package to install. Ruled NOT a parity difference, NOT a semantic difference and NOT a Stage-3 blocker -- the default is rust, Python stays the explicit rollback until Stage 4, the Rust toolchain is installed by the Action rather than assumed, and what is built is the production crate, never own-shadow-engine or a test adapter. So it is not an undeclared runtime dependency; it is a heavy way to deliver a binary. A consumer today pays setup-python, setup-dotnet, setup-rust and a cargo build to run a static analyzer. EXIT CONDITION: the first suitable published own-cli/Owen.Cli artifact, at which point the Action downloads an immutable platform binary and the consumer-side Rust build disappears -- a separate post-Stage-3 packaging follow-up, deliberately NOT Stage 4, which is about removing the Python distribution dependency. HARDENED for reproducibility at the ruling: rustc pinned to the concrete qualification toolchain (1.98.1) instead of the moving `stable` channel, and `cargo build --locked` so an unchanged source revision cannot silently resolve a different dependency graph. The build cache is an optimization and the Cargo build is authoritative: an earlier comment claimed the cache key gives a caller who bumps a pinned version a rebuild, which it does not -- a moving major tag keeps one key across every commit it points at. + +## Closed by this change (recorded as closures, not as differences) + +* **UTF8-TAIL** — _#262 known difference: invalid UTF-8 escaped the reference's load() and surfaced as rc 70_ + CLOSED — both engines refuse it as input at rc 2 with a byte-identical message. Recorded as a closure, NOT as a standing difference. +* **V1** — _#262 V1: the reference accepted NaN/Infinity/-Infinity and described a float the source never contained_ + CLOSED — refused at the JSON door on both sides; the residual text difference is CLI-B1, which already existed. +* **V2-ACCEPTANCE** — _#262 V2: the reference ACCEPTED a top-level -0 as v0 and analysed the document_ + CLOSED as an acceptance divergence; narrowed to CLI-B2 above. +* **V3** — _#262 V3: oversized integral version values_ + Already closed at #261; VERIFIED not reopened by the parse_int hook the V2 fix installs, at both signs. +* **PS1-CAPTURE** — _a REGRESSION the cutover introduced in the own-check.ps1 surface, found by the qualification round: PowerShell's call operator routes a child's stdout through its PIPELINE, which is what makes `$out = & ./scripts/own-check.ps1 ...` capture anything. The Python branch uses the call operator; the Rust branch spawned a process that inherited the console handle and so bypassed the pipeline. While Python was the default nobody noticed. The moment Rust became the default, every caller capturing or piping this script's output silently got NOTHING -- the text still appeared on screen, so it looked fine_ + CLOSED. The candidate's streams are redirected and replayed by the caller: stdout to the pipeline, stderr to the error stream, decoded as UTF-8 with no BOM and both pipes drained concurrently so a large SARIF log cannot deadlock. MEASURED after: both engines capture the same single OWN001 line and the captured arrays are IDENTICAL; exit tiers unchanged (1 with -FailOnFinding, 0 without); SARIF is 1413 bytes from either engine, no BOM, parses, one result. Not a declared difference -- an accidental behaviour change beyond the engine, which #262's guardrails forbid, so it was repaired rather than recorded + +## Measurements + +* `fast-compare` [linux] **MEASURED OBSERVATION** — 104 documents, 104 agreed, 0 acceptance-unexplained +* `samples-compare` [linux] **MEASURED OBSERVATION** — 1 document, 1 agreed, 0 acceptance-unexplained +* `compare-driver-controls` [linux] **MEASURED OBSERVATION** — 10 double-driven + 6 manifest/identity + 5 raw-variant + 3 negative controls held +* `broad-sweep` [linux] **MEASURED OBSERVATION** — 10 documents over 6 targets, all agreed, 0 acceptance-unexplained, 0 declared-boundary +* `cli-replay` [linux] **MEASURED OBSERVATION** — 94 frozen cases replayed byte-for-byte; 2 declared boundaries (CLI-B1, CLI-B2) +* `hygiene-tails` [linux] **MEASURED OBSERVATION** — invalid UTF-8 rc 70 -> rc 2 (byte parity); V1 moved to the JSON door; V2 top-level -0 was ACCEPTED as v0 and is now refused; nested -0 unmoved +* `packaging-linux` [linux] **MEASURED OBSERVATION** — bare `owen check` -> OWN001, rc 1, no Python present; --engine python -> same finding; packaged binary chmod 644 -> materialised to ~/.owen/rust-core/ and ran; packaged binary absent -> rc 2 denying a fallback +* `packaging-windows` [windows] **MEASURED OBSERVATION** — PASS. The job concluded success, and it carries the unconditional Stage-3 assertion that a bare `owen check` with a deliberately unusable OWEN_PYTHON still finds OWN001 at rc 1 and never consults the development locator -- under `bash -e` with an explicit `exit 1`, so a success conclusion is that step passing. Nothing here is carried over from the Linux row: this is windows-latest reporting on itself. Its install -> check -> findings, clean-code, uninstall/reinstall and vendored-cache steps passed in the same job +* `cancellation-linux` [linux] **MEASURED OBSERVATION** — python reference: SIGINT -> died by signal 2, stdout 0b, stderr 1478b (a KeyboardInterrupt traceback); rust own-cli: SIGINT -> died by signal 2, stdout 0b, stderr 0b. Neither has an exit code in that state; neither returned a verdict +* `cancellation-windows` [windows] **MEASURED OBSERVATION** — PASS, and the disposition is NOTHING like the Linux one, which is why #262 forbade inventing a universal 130. Both engines interrupted while genuinely running: python reference CTRL_BREAK_EVENT -> EXITED with code 3221225786 (0xC000013A, STATUS_CONTROL_C_EXIT), stdout 0b, stderr 0b; rust own-cli the same, 3221225786, stdout 0b, stderr 0b. On Linux both DIE BY SIGNAL 2 and have no exit code at all. Neither platform is 130. Invariants held on both: terminated, never a verdict code (0/1), no ok line, no findings summary +* `rollback-linux` [linux] **MEASURED OBSERVATION** — all four held on both surfaces: default=Rust; explicit python agrees on the verdict; broken candidate with nothing asked -> rc 2 denying a fallback; broken candidate with python asked -> python runs +* `rollback-windows` [windows] **MEASURED OBSERVATION** — PASS. All four states held on both runnable surfaces (own-check.sh under git-bash, and owen): default=Rust; explicit python agrees on the verdict; broken candidate with nothing asked -> exit 2 denying a fallback in as many words; broken candidate with python asked -> python runs anyway +* `stage1-controls` [linux] **MEASURED OBSERVATION** — 19 controls passed, 0 failed, 0 skipped — including default-is-rust and unset-locator-is-d6 +* `stage2-controls` [linux] **MEASURED OBSERVATION** — 9 controls passed, including public-default-is-rust +* `ci-surfaces` [n/a] **MEASURED OBSERVATION** — 63 invocations over 26 files; 23 explicit, 40 bare and all resolvable; 0 hollow Python injections +* `stage1-controls-windows` [windows] **MEASURED OBSERVATION** — 19 controls passed, 0 failed, 0 skipped, 1 not applicable (a non-executable candidate cannot be constructed for the shell surface under git-bash). Includes default-is-rust, unset-locator-is-d6, rust-failure-no-fallback and an unexpected child status mapped to public exit 5 with the raw WINDOWS-NATIVE status (-1073740791) retained. The job as a whole went red on a HARNESS defect in the Stage-3 rollback control that ran after these, not on any of them +* `terminal-ci` [n/a] **MEASURED OBSERVATION** — 31/31 jobs green, 0 failures, 0 non-success, on b22543680da9 -- the implementation qualification point. The Stage-3 decision was established on f1d3681e96e3 (run 35312190310, 31/31); the cleaned head differs from it by the reconciliation record 7622a3b and the repository-hygiene commit b225436, which untracked 223 files of Cargo build output under rust/target-fault, so the matrix was re-run rather than inherited. The terminal MERGE qualification is CI on the reconciliation-only head that carries this record; its canonical SHA and run are recorded on #262, never inside the commit that creates the SHA. Includes both Stage-1 control legs, both packed-artifact legs, both dogfood legs, the Windows-native and Stage-2 Windows campaigns, own-check.ps1's exit-code tiers, both shadow-compare gates, the Rust-default code-scanning dogfood, rust fmt/clippy/tests and the Python suite on 3.11/3.12/3.13 diff --git a/docs/notes/owen-engine-rollback.md b/docs/notes/owen-engine-rollback.md new file mode 100644 index 00000000..7319ab21 --- /dev/null +++ b/docs/notes/owen-engine-rollback.md @@ -0,0 +1,106 @@ +# Selecting Owen's analysis engine, and rolling back + +Owen has two analysis engines behind one launcher. Since **P-022 Stage 3** +(#262) the default is the **Rust core**; the **Python reference** remains +available and supported, and selecting it is the documented rollback for the +observation window. + +This is the one canonical place that says how. If another document disagrees +with this one, this one is right. + +## The selection + +One selector, the same three spellings, on every launcher surface: + +| surface | default (Rust) | roll back to Python | compare | +| --- | --- | --- | --- | +| `owen` | `owen check ` | `owen check --engine python ` | `--engine compare` | +| `scripts/own-check.sh` | `own-check.sh ` | `own-check.sh --engine python ` | `--engine compare` | +| `scripts/own-check.ps1` | `own-check.ps1 ` | `own-check.ps1 -Engine python ` | `-Engine compare` | +| Owen Action | (nothing) | `with: { engine: python }` | `engine: compare` | + +`compare` runs both engines over one captured input and exposes the reference's +result **only** when they agree byte for byte. It is a development/CI seam for +the migration, not a promised public feature. + +## Rolling back + +Add the flag. That is the whole mechanism, and it is deliberately the whole +mechanism: + +```yaml +# .github/workflows/your-workflow.yml +- uses: PhysShell/Own.NET@v1 + with: + path: src + engine: python # roll back to the reference engine +``` + +```bash +owen check --engine python src +``` + +There is no environment variable that changes the engine, and that is on +purpose: an ambient setting is a thing you forget you set, and "which engine +produced this finding" would stop being answerable from the command that +produced it. + +### What rollback is not + +* **Not automatic.** Nothing falls back. If the Rust core fails, you get the + Rust core's failure — visibly — and Owen does not quietly run Python and + present its answer as though nothing happened. A result from an engine you + did not choose is worse than an error, because you cannot tell. +* **Not a moved tag.** Release tags are immutable and are never re-pointed. A + broken release is corrected by a **new patch release**. +* **Not Python's removal, and not its deprecation.** Stage 4 will remove the + Python core from the *distribution*; until then it ships, it works, and it is + supported. Stage 3 changed which engine answers when you say nothing. + +## When the Rust core cannot be used + +Both failures are **configuration errors (exit 2)**, never a fallback, and both +say so in as many words: + +* the packaged binary is missing from an installed `owen` — a packaging or + install fault; +* `OWEN_RUST_CORE` is set to something unusable. + +Either way Owen tells you what it looked for and offers `--engine python`. It +does not decide for you. + +## `OWEN_RUST_CORE` is for developers + +`OWEN_RUST_CORE` says **where** a candidate `own-cli` binary is. It does not say +*which engine* to run — that is `--engine`. When it is set it wins; when it is +absent, an installed `owen` uses the binary inside its own package (#262 D6). + +There is **no discovery**: no `PATH` lookup, no `rust/target` probing, no "first +binary found". Those are how a stale binary from some earlier build silently +stands in for the one you meant to run. + +The two shell surfaces run from a **checkout** and have no package to fall back +on, so running them with the default engine needs a candidate: + +```bash +cd rust && cargo build -p own-cli --release +export OWEN_RUST_CORE="$PWD/target/release/own-cli" +``` + +…or pass `--engine python` and skip the build. + +## What is tested, and where + +Rollback is not documentation-only. `tests/test_stage3_rollback.py` drives four +states on every runnable launcher surface and requires them to stay **distinct**: + +```text +1 default, candidate fine -> Rust runs +2 --engine python -> Python runs, and AGREES with 1 +3 candidate broken, nothing asked -> visible failure (exit 2), fallback denied +4 candidate broken, python asked -> Python runs anyway +``` + +3 and 4 are one flag apart and are the pair that matters: a launcher with a +hidden fallback makes 3 look like 4. 1 and 2 are what stop 3 passing for a +launcher that simply never worked. diff --git a/docs/notes/p022-stage3-cutover-decision.md b/docs/notes/p022-stage3-cutover-decision.md new file mode 100644 index 00000000..466a0749 --- /dev/null +++ b/docs/notes/p022-stage3-cutover-decision.md @@ -0,0 +1,383 @@ +# P-022 Stage 3 — the public Rust-default cutover decision + +> **What this file is.** The decision surface for #262 Stage 3, frozen *before* +> the implementation it authorizes. It records the contract, the owner rulings +> that amend it, the acceptance predicates and their classification, and — at +> the end — the decision packet #262 requires. It is written to be readable +> against the tree rather than against memory: every claim below is labelled +> with what kind of claim it is. +> +> Stage 3 moves the **default engine**. It does not move the **analysis +> semantics**. The P-022 feature freeze on verdict-changing inference stays in +> force through the cutover. + +## Evidence labels + +Used verbatim throughout, and never silently exchanged for one another: + +| label | meaning | +| --- | --- | +| `REPOSITORY FACT` | true of this tree, checkable by reading or running it | +| `MEASURED OBSERVATION` | produced by an execution recorded here, on a named platform | +| `OWNER RULING` | a decision by the repository owner; not derivable from the tree | +| `DECLARED BEHAVIOR CHANGE` | a deliberate, recorded change in what users observe | +| `DECLARED BOUNDARY` | a difference ruled out of a parity denominator, by policy | +| `INFERENCE` | reasoning from the above; never a substitute for measurement | +| `DEFERRED EVIDENCE` | owed, not taken. **Never** reported as PASS | + +## Stage header + +```text +STAGE: P-022 Stage 3 — public Rust-default cutover +BASE SHA: 70189a3de832af51419d0ba6d80572ea7260939d (origin/main) +OWNER DECISION: the public default may move to Rust if all NON-PERFORMANCE + Stage-3 gates pass +PERFORMANCE: deferred by owner (see below) +#263: remains open; remains the performance-baseline tracker +P-037 / A1: remains blocked until Stage 3 is complete +STAGE 4: explicitly out of scope — Python distribution removal is a + separate, separately reviewable PR after the observation policy +``` + +## OWNER RULING — performance is deferred + +`OWNER RULING`, recorded verbatim as the amendment to #262's evidence policy: + +> Performance evidence is deferred by owner for the Stage-3 public-default +> decision. Stage 3 makes no performance claim. #263 remains required before any +> published/reproducible performance claim and remains the performance-baseline +> tracker. + +This is an **amendment to the cutover evidence policy**. It is *not* a claim +that the measurements happened, and it is *not* a pass of #262's "Performance +gates" section. #262's prose still says #263 is the evidence prerequisite of the +decision; that sentence is amended here and nowhere else, narrowly, and only for +the Stage-3 public-default decision. + +Accordingly, for this decision and no other: + +```text +#263 / performance measurements DEFERRED BY OWNER +physical-host qualification DEFERRED +startup delta NOT MEASURED +end-to-end delta NOT MEASURED +peak memory delta NOT MEASURED +performance acceptance NOT CLAIMED +``` + +Nothing in this note infers that Rust is faster, slower, or equivalent. Any +timing encountered incidentally while taking correctness evidence is **not** +promoted into a performance claim, and no latency budget is invented. + +## Non-negotiable boundary + +`OWNER RULING` / `REPOSITORY FACT`. Stage 3 changes the default engine, not the +analysis semantics. This change therefore does not: change diagnostic rules or +severity; alter `ConsumesParam`; broaden or narrow Roslyn inference; implement +guarded summaries or any part of P-037; "clean up" Python semantics; +regenerate expectations to hide divergence; or accept a Rust/Python semantic +difference merely because Rust is about to become the default. + +An unexplained Rust/Python divergence remains a **bug** until the cutover +decision is complete. There is no silent fallback anywhere. + +The three P-037 known-false-positive controls keep their **current** expected +behaviour through this change. Their movement during the cutover would be a +scope-discipline regression, not a fix. + +## Acceptance predicates of #262, classified + +Every Stage-3 predicate #262 states, and nothing else, classified as +`ALREADY SATISFIED` / `MUST CLOSE NOW` / `DEFERRED BY OWNER — PERFORMANCE ONLY` +/ `NOT A STAGE-3 REQUIREMENT`. No requirement outside the performance section is +deferred; where a genuine prerequisite was missing it is closed rather than +weakened. + +### Correctness gates + +| # | predicate (#262) | classification | +| --- | --- | --- | +| C1 | #260 fast and broad compare matrices report zero unexplained differences | ALREADY SATISFIED at #260 acceptance; **re-qualified on the Stage-3 candidate tree** (a prior run is not evidence for a changed tree) | +| C2 | five pinned OSS repositories report zero unexplained differences | ALREADY SATISFIED at #260; re-qualified as above | +| C3 | full diagnostic/message/Evidence/SARIF parity green | ALREADY SATISFIED (#259 final acceptance); re-qualified | +| C4 | production OwnIR executable command/output/exit parity (#261) green | ALREADY SATISFIED (#261 closed, PR #347, `206e9c7`); re-qualified, and **extended** by the hygiene tails below | +| C5 | no severity or diagnostic-count drift without a separate Python-first decision | MUST CLOSE NOW — held by the Phase-6 re-qualification | +| C6 | #345 residual `.own`/dev CLI | NOT A STAGE-3 REQUIREMENT (owner decision C-5) | + +### Python-first cutover hygiene (recorded by #262 as owed *before* public cutover) + +| # | predicate | classification | +| --- | --- | --- | +| H1 | invalid UTF-8: `UnicodeDecodeError` → `OwnIRError` → rc 2 | **MUST CLOSE NOW** | +| H2 | V1 non-finite constants rejected at the JSON door → rc 2, frozen message | **MUST CLOSE NOW** | +| H3 | V2 literal top-level `-0` rejected at the OwnIR input boundary | **MUST CLOSE NOW** | +| H4 | V3 oversized integral version → Version mismatch branch, byte parity | ALREADY SATISFIED (#261 repair-2). Verified, not reopened | +| H5 | V4 Unicode-table representation boundary | DECLARED BOUNDARY, unchanged. Reopen predicate has **not** fired — verified below | + +### Reliability gates + +| # | predicate | classification | +| --- | --- | --- | +| R1 | malformed OwnIR / malformed source do not panic | MUST CLOSE NOW (re-evaluated against the *production* surfaces) | +| R2 | Rust crashes visible, with reproduction artifacts | ALREADY SATISFIED (#261 fault-injection; Stage-1 D5) — re-qualified | +| R3 | unexpected Rust child exit → public internal-error path, raw status retained | ALREADY SATISFIED (Stage-1 D5, report schema 2) — re-qualified | +| R4 | no Rust failure silently falls back to Python | ALREADY SATISFIED (Stage-1) — re-qualified **on the new default** | +| R5 | deterministic reruns produce identical normalized output | MUST CLOSE NOW | +| R6 | cancellation/interruption tested against the **measured** reference | **MUST CLOSE NOW** — behavioural evidence, not a benchmark; `130` is not invented as universal | +| R7 | memory/resource limits for hostile or very large inputs documented | MUST CLOSE NOW (documented per existing policy; **not** a perf measurement) | +| R8 | Windows and Linux clean-machine paths covered | MUST CLOSE NOW | + +### Distribution gates + +| # | predicate | classification | +| --- | --- | --- | +| D-a | Rust binary packaged for all supported platforms | **MUST CLOSE NOW** | +| D-b | public `Owen.Cli` install and Action work without undeclared runtimes | **MUST CLOSE NOW** — on the Rust-default path this means *without an undeclared Python runtime* | +| D-c | package upgrade/uninstall/reinstall tested | MUST CLOSE NOW (the release workflow already has the surface) | +| D-d | rollback engine selection documented and tested | **MUST CLOSE NOW** | +| D-e | release workflow tests the actual packed artifact, not a project build | ALREADY SATISFIED in shape (`smoke-test` installs the nupkg) — **extended** to the Rust-default path | + +### Performance gates + +| # | predicate | classification | +| --- | --- | --- | +| P1..P7 | startup, OwnIR parse, bridge/lowering, analysis, rendering, end-to-end, peak RSS | **DEFERRED BY OWNER — PERFORMANCE ONLY** | + +### Acceptance + +| # | predicate | classification | +| --- | --- | --- | +| A1 | explicit owner-approved cutover decision exists | this note is the decision surface | +| A2 | Rust is public default only after all gates pass | gated on the above | +| A3 | rollback path tested and documented | MUST CLOSE NOW | +| A4 | observation-period results recorded | MUST CLOSE NOW | +| A5 | Python distribution removal in a later, separately reviewable PR | **Stage 4 — out of scope here** | +| A6 | public Owen install/Action behaviour correct on supported platforms | MUST CLOSE NOW | + +## Starting state, verified from the tree + +`REPOSITORY FACT`, reconciled at base SHA `70189a3`: + +| claim | verified | +| --- | --- | +| #259 final acceptance reached | yes — P-022 status table row, `p022-bridge-verdict-final-acceptance.md` | +| #260 shadow/compare acceptance reached | yes — row 7a | +| #261 production `own-cli` reached | yes — row 7b; PR #347 merged `206e9c7`; #261 closed | +| Stage 1 landed | yes — `--engine` selector, `OWEN_RUST_CORE` locator, D4/D5 | +| Stage 2 landed | yes — `docs/evidence/p022-stage2-census.json`, `tests/test_stage2_dogfood.py` | +| Stage 3 NOT landed | yes — all four launcher surfaces default to Python | +| Python is the public/default reference | yes — `EngineSelection.Default`, `own-check.sh` `engine="python"`, `own-check.ps1` `$Engine = "python"`, `action.yml` `default: "python"` | +| Rust is the repository dogfood default | yes — Stage 2 census, Class-D call sites | +| compare gates still active | yes — `shadow-compare`, `shadow-compare-samples`, `shadow-sweep.yml` | +| #345 not on the cutover path | yes — owner decision C-5 | +| #257 not a Stage-3 blocker | yes — no verified dependency found | + +The Stage-3 branch is cut from this base. `REPOSITORY FACT`: at the time of +cutting, the branch had **zero** commits of delta against `origin/main`, so no +P-036/P-037 research work is carried into the cutover. + +## D6 — packaged resolution of the Rust core + +`REPOSITORY FACT`. `RustCoreLocator` is documented in-tree as the **Stage-1 +development** locator, and says so explicitly: + +> *"Stage 3's packaged resolution is D6's problem, not this one's — do not solve +> packaging here."* + +`INFERENCE` from that fact: making Rust the default without D6 would make a bare +`owen check` on a user's machine exit 2, because `OWEN_RUST_CORE` is unset. +Stage 3 therefore must land D6, and D6 is defined here as: + +```text +D6 PACKAGED RESOLUTION + The `own-cli` binary for the running platform ships INSIDE the Owen.Cli + payload at a deterministic path, and is resolved from AppContext.BaseDirectory + exactly the way CoreVendor already resolves the vendored Python core. + + This is packaged resolution, NOT discovery: + - no PATH lookup + - no rust/target/{debug,release} probing + - no "first binary found" + - no network fetch, no download, no cache population + OWEN_RUST_CORE keeps its Stage-1 meaning and PRECEDENCE: an explicitly set + locator still wins, still resolves exactly as before, and its failures are + still D3.1 configuration errors (exit 2). + + A missing or unusable packaged binary is a VISIBLE configuration/production + error on the D3.1 path (exit 2) with one actionable diagnostic. It is never + a reason to run Python. +``` + +## Rollback contract + +`OWNER RULING` + `REPOSITORY FACT`. Rollback is the already-ratified +engine-selection surface, made explicit and tested. It is **not** automatic, and +it is **not** a moved release tag. + +```text +no explicit engine selection -> Rust (Stage 3 default) +explicit `--engine python` / OWEN_ENGINE=python -> Python (the rollback) +explicit `--engine compare` -> compare, where the surface exposes it +Rust failure -> visible Rust failure, ALWAYS +Rust failure -> NEVER an automatic Python success +``` + +Four states are kept distinct and separately tested; two of them are commonly +conflated, which is why they are named apart: + +1. default → Rust; +2. explicit rollback → Python; +3. Rust broken/unavailable **and no rollback requested** → visible failure; +4. Rust broken/unavailable **and Python explicitly selected** → Python runs. + +A broken published release is repaired by a **new patch release**. Immutable +release tags are never moved. + +## Scope ledger for this change + +Allowed and performed: cutover-specific launcher selection; the Python-first +hygiene tails #262 owes; the Rust parity reconciliation those tails require; +packaging; Action wiring; the rollback mechanism, its tests and its docs; +reliability and cancellation tests; Stage-3 evidence; status/doc reconciliation. + +Forbidden and not performed: new analysis features; P-037 production code; a +`ConsumesParam` fix; unrelated refactors; performance optimization; #263 +implementation; physical-host measurement infrastructure; Stage-4 Python +removal; #345 work; #257 work. + +## Decision packet + +**Generated, not written.** The packet lives at +[`docs/generated/p022-stage3-packet.md`](../generated/p022-stage3-packet.md) and +is produced by `scripts/stage3_packet.py` from the measurement ledger +[`docs/evidence/p022-stage3-cutover.json`](../evidence/p022-stage3-cutover.json). +No number in it is typed. + +`tests/test_stage3_packet.py` holds the ledger to the rules that make the +derivation worth anything — each named for the misreading it stops, and each +mutation-proved: a deferral cannot soften into a claim, performance language +cannot appear outside the deferred fields, an OWED Windows row cannot be filled +in from the Linux one beside it, a bug this change CLOSED cannot be re-listed as +a standing difference, a ratified difference cannot go missing, Python removal +must say Stage 4, and the committed packet must match what the ledger produces. + +## Final status + +The state these surfaces must agree on, and do: + +```text +Stage 1 DONE +Stage 2 DONE +Stage 3 DONE — Rust is public default +Stage 4 NOT STARTED — Python distribution removal remains separate + +Python: explicit rollback/reference available during the observation policy +Rust: public/default production engine +compare: retained as development/CI evidence +performance: DEFERRED BY OWNER; no Stage-3 performance claim; #263 remains open +``` + +P-022 is **not** complete: Stage 4 is open and #263 still owes the baselines +this decision deferred. + +## Terminal evidence + +Three qualification points, kept apart because they answer three different +questions: + +```text +Decision qualification: f1d3681e96e31bba7a36dda09967d0af7c3e9b56 + 31/31 jobs green + https://github.com/PhysShell/Own.NET/actions/runs/35312190310 +Implementation qualification: b22543680da9e8fa6b2607435bb54a5437815bff + 31/31 jobs green + https://github.com/PhysShell/Own.NET/actions/runs/35320877147 +Terminal merge qualification: established by CI on the reconciliation-only + head that carries this record; canonical SHA + and run recorded on #262, never inside the + commit that creates the SHA +``` + +The decision was established on `f1d3681`: every predicate below is confirmed +on **that** commit, not assembled from earlier ones — both Stage-1 control +legs, both packed-artifact legs, both dogfood legs, the Windows-native and +Stage-2 Windows campaigns, `own-check.ps1`'s exit-code tiers, both +shadow-compare gates, the Rust-default code-scanning dogfood, rust +fmt/clippy/tests, and the Python suite on 3.11/3.12/3.13. + +The implementation candidate `b225436` is that tree plus the reconciliation +record `7622a3b` and one repository-hygiene commit: `04c3303` had committed +223 files of Cargo build output under `rust/target-fault/`, the build +directory of the fault-injection candidate. Build output is not evidence and +is never versioned, so it was untracked and both Cargo directories are now +ignored explicitly. No production behaviour changed, and the decision run +rightly never saw the difference: CI checks the program, not the tree's +manners. The full matrix was nevertheless re-run on the cleaned head rather +than inherited from its ancestor, because the rule this record lives by is +that the SHA which goes to `main` is qualified as itself. + +The SHA that goes to `main` is the reconciliation-only head carrying this +record. A commit cannot name its own SHA — recording it here would be a +fixed-point hunt, not evidence — so the terminal merge qualification is CI on +that head, and its canonical SHA and run are recorded externally on #262. + +## Defects found and closed during qualification + +Recorded as **closures**, deliberately not under *Known differences*: after the +fixes these are not permitted Stage-3 behaviour. All three were invisible while +Python was the default, and all three were found by running the thing rather +than by reading it — which is the entire argument for the qualification round. + +They also form one sequence, and it is worth seeing whole: + +```text +1. an invocation that could reach no engine at all +2. a capture that captured nothing +3. an assertion that passed BECAUSE the capture was empty +``` + +Each looked perfectly respectable on its own. + +* **PS1-CAPTURE.** `own-check.ps1`'s Rust branch spawned a child that inherited + the console handle, bypassing PowerShell's pipeline, so + `$out = & ./scripts/own-check.ps1 ...` captured nothing while the text still + appeared on screen. Closed by redirecting and replaying the streams. +* **PS1-NOTMATCH-ASSERTION**, and its scope stated precisely because the loose + version would be untrue: it is the **Windows Stage-2 dogfood's OWN001 + output-observation assertion** that had never constituted evidence on the + Rust path — before the capture repair it received `$null`, and PowerShell's + array/filter semantics make an empty capture pass. The other Stage-2 controls + and the actual Rust-default execution (exit code, no-fallback, candidate + identity) remain separately evidenced. This is **not** a claim that Stage 2 + as a whole was unproven. +* **CI-SURFACES-HOLLOW.** Five steps injected a broken Python and then invoked + a launcher bare, so the injected fault could no longer happen and each would + have passed vacuously; two more call sites lived inside scripts rather than + workflow YAML. + +## Hand-off + +```text +P-037 A1 PRODUCTION GATE: BLOCKED BY DESIGN -> UNBLOCKED +``` + +The P-022 freeze on verdict-changing inference held until the cutover +completed; this commit completes it. A1 is **not started here**, and not in +this PR: it is a semantic change and this was a cutover, and #262's guardrails +say the two are never mixed. + +A1 begins on a new branch from the Rust-default baseline, which keeps the +provenance line legible: + +```text +last P-022 semantic state + | + +-- Stage-3 decision qualification (f1d3681) + +-- Stage-3 implementation qualification (b225436; hygiene only) + +-- Stage-3 terminal merge candidate (the record head; SHA on #262) + | + +-- P-037 A1 starts here, from the Rust-default baseline +``` + +Nobody should have to work out, later, whether the first `ConsumesParam` change +belonged to the Rust migration or to a new inference feature. diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index 4c76f48d..71ef74e9 100644 --- a/docs/proposals/P-022-rust-core-migration.md +++ b/docs/proposals/P-022-rust-core-migration.md @@ -1,9 +1,13 @@ # P-022 — Rust core migration: bird's-eye architecture -Status: **in execution** (strangler-fig underway; the Python core stays the -reference implementation and the oracle until the explicit cutover). The design -rationale below is historical and unchanged; the live sequencing is the #250 -child-issue DAG. Revised per the post-merge review in +Status: **in execution** — the cutover has happened and P-022 is NOT finished. +Since #262 Stage 3 the **Rust core is the public default engine**; the Python +core remains the reference implementation and the oracle, and is the tested +rollback for the observation window. Stage 4 (removing Python from the +distribution) has not started, #263 still owes the performance baselines the +Stage-3 decision explicitly deferred, and the compare gates are retained as +development/CI evidence. The design rationale below is historical and +unchanged; the live sequencing is the #250 child-issue DAG. Revised per the post-merge review in [`docs/notes/p022-review-notes.md`](../notes/p022-review-notes.md). ## Implementation status @@ -76,9 +80,9 @@ was #258 alone, which is satisfied. Per the checkpoints #259 itself defines: | 6b | Rust `own-bridge`, layered OwnIR parity | #259 | **final acceptance reached** — see the checkpoint table and the line above it | | 7a | dual-engine shadow mode + zero-diff reproduction artifacts | #260 (supported by #269) | **final acceptance REACHED**. The only wording it earns: *dual-engine compare mode reports zero acceptance-unexplained over its full test matrix — the committed corpus, the C# samples, the examples, the five pinned OSS repositories of #243 and the large-solution controls — at all three layers and on the derived SARIF, on byte-attested same input, with the OD-1 typed-door boundaries declared by policy; Python remains the public engine.* It is **not** "P-022 done" and **not** "Rust is the default", which is #262's cutover behind #261. The sweep is what the acceptance surfaces over the committed corpus deliberately left owed: ten documents over six targets, each repository at its **verified** pin (drift is a failed target, never a newer measurement), each extracted **once** through `own-check.sh --emit-facts` and compared from those bytes — the five directory walks, the largest `.sln` of every target that has one (a different extractor path, and measurably a differently *ordered* document rather than a subset), and `examples/`. Coverage is defined so that it cannot be faked: a repository is not covered because extraction succeeded, so the driver fails a run that compared zero documents AND a declared target it never reached, and the **denominators are recorded per target**. The driver gained the identity the #342 review asked for — every result and failure report names the adapter by `sha256` and byte length, taken from the file that ran — plus manifest runs whose every document is verified against its `facts_sha256` before any engine starts (`shadow_compare_version` 2; the artifact format v3 is untouched). Taking the measurement found six HARNESS defects and no engine divergence: a cross-drive `relpath` that killed the driver on a label, a timeout that never returned when the adapter had children, a control group that could not execute on Windows at all (and so had never caught the timeout one), and three in the mutation harness that between them meant no campaign could be recorded anywhere but Linux — rewritten line endings that made it refuse its own run, a catcher name that took the host's path separator and so reported five protected rules as unprotected, and a layer decoded with the console codepage. The five repositories' facts documents are not committed — their identities are. The scheduled/manual gate is `.github/workflows/shadow-sweep.yml`; every count lives in the generated fragments ([sweep](../generated/p022-shadow-sweep.md), [census](../generated/p022-shadow-census.md), [campaigns](../generated/p022-shadow-mutations.md)) and never here; the records are [the sweep note](../notes/p022-shadow-sweep.md) and [the acceptance note](../notes/p022-shadow-acceptance.md), which name what is measured-not-claimed. The owner decisions remain D-4..D-7, B-2, B-3, R-1 and R-2 in [the ledger](../notes/p022-shadow-infra-owner-decisions.md), unreopened. No production behaviour changed | | 7b | Rust `own-cli`: the production OwnIR executable — command/output/exit-code parity behind the existing launcher | #261 (residual `.own`/dev CLI: #345) | **261.A ratified; 261.B built, repaired to the ratified acceptance, and replaying on both platforms; #261 closed completed 2026-09-08 (PR #347, `206e9c7`).** Owner decisions C-1..C-5 (2026-09-08, recorded verbatim in #261) are unchanged and were applied, not re-litigated. What exists now: the `own-cli` binary with its single `ownir` subcommand, a Python-authored CLI fixture family (`tests/fixtures/cli_ownir/`) replayed against the built binary with **zero Python** on Linux and Windows CI, and an off-by-default `fault-injection` feature under which both failure-mode rulings are MEASURED: a catchable panic is one actionable stderr diagnostic and exit 70 (never 101) via a hook plus a top-level `catch_unwind` under `panic = "unwind"`, and an uncatchable death is a visible hard failure with no OS exit number contracted. The top-level shell follows the public `owen` convention as a parity surface of its own, written once and shared between the binary and the fixture; everything after `ownir` is the reference's own behaviour as measured, the docstring-on-stdout class frozen AND flagged so the owner can declare it a defect knowing what was frozen. The renders are reused, never re-derived; what the CLI adds is the SARIF serialization the reference's `cmd_ownir` uses (`json.dumps(indent=2)`, ASCII-escaped), which is not the BR-V9 goldens' byte shape. DAG: `own-cli -> own-ir`/`own-bridge` and nothing else. 261.B is built and replaying on both platforms, with the four rulings settled as follows: (2a) the `ownir_version` Version messages are **byte-parity** — that text is ours on both sides, so the divergence was a Rust bug and was fixed rather than declared. A second repair pass re-took that measurement over value **classes** rather than four hand-picked values and found three more defects a single-key, integer-valued control could not reach (CPython's dict order, its float spelling, and the arbitrary-precision integer that changes which branch the reference takes), plus a round-half-to-even tie found by a 200 000-double sweep. Re-measured: 24/24 classes and 20 000 randomized documents byte-identical, with V1 (the reference's non-standard JSON constants), V2 (the literal `-0`) and V4 (the two sides' independently versioned Unicode tables — a representation-only boundary, the mismatch count being a specific two-version measurement recorded in the note, not a fixed size) declared and excluded rather than counted. The census is now a Rust test replayed with zero Python; (2b) the JSON parser detail is a **declared typed boundary, CLI-B1** — the CLI-owned wrapper `{path}: error: {path} is not valid JSON: ` is pinned byte-exact and only the parser library's own text after it is declared, guarded by an executable `kind == Json` proof and a negative control that runs ONE case — one argv, one exact path, one decode route — against two byte sequences, so eligibility can only turn on the facts bytes; a Json rejection that loses its internal prefix fails onto rc 70 rather than passing through; (1) invalid UTF-8 is a **declared defect of the Python reference**, excluded from the byte contract pending a Python-first hygiene tail (`UnicodeDecodeError` -> `OwnIRError` -> rc 2) to close before public cutover, recorded in #262 — the tracker of record — and mirrored in #250's Still missing list; (3) Windows is **A** canonical reference parity plus **B** Rust portability, with **C** native-Windows Python parity explicitly **NOT claimed** — the reference there emits cp1252/CRLF and can fail with `UnicodeEncodeError`, recorded in #262 — the tracker of record — as a behavior change rather than parity, and mirrored in #250's Still missing list. Every count is generated (`docs/generated/p022-cli-census.md`, `docs/generated/p022-cli-mutations.md`); the record is [the note](../notes/p022-cli-ownir.md). The executable lives **behind** the unchanged `owen` launcher: nothing is wired, published or defaulted — that is #262 | -| 8 | Rust-default **cutover**, rollback gate, Python distribution removal | #262 | **Stage 1 landed: the Rust core is opt-in behind the existing launcher.** **Stage 2 landed: Own.NET's own CI and dogfood select Rust by default.** That is a statement about THIS repository's internal engine and nothing else — the public contract is unmoved, and all four launcher surfaces (`owen`, `own-check.sh`, `own-check.ps1`, the Action) still resolve Python when asked for nothing. The Rust-default population is named rather than asserted: `docs/evidence/p022-stage2-census.json` classifies EVERY CI call site that executes the core or a launcher surface as a public-contract verifier, an explicit reference, a compare gate or operational dogfood, and `tests/test_stage2_dogfood.py` enumerates the workflows itself, so an unclassified call site and a stale ledger entry both fail. Every Class-D dogfood call site selects Rust EXPLICITLY through the ratified `OWEN_RUST_CORE` locator with the production `own-cli` built in the job and its sha256 recorded; no discovery, no dev adapter, no test stub. The compare gates and the explicit Python reference paths are unchanged, because Rust exposure is never bought with differential evidence. Stage 2 is **not** the cutover: Stage 3 remains gated by its own owner decision and its evidence prerequisites. Its #261 prerequisite — the production OwnIR executable alone (C-5) — is satisfied: 261.B landed (PR #347, `206e9c7`) and #261 is closed completed; #260 reached; #345 is not on this path. Stage 1 is **not** a cutover: Python remains the default and the reference on all four launcher surfaces (`owen`, `own-check.sh`, `own-check.ps1`, the Action), nothing public defaults to Rust, and Python distribution is untouched. What exists now: one explicit `--engine python|rust|compare` selector (D1), the ratified `OWEN_RUST_CORE` candidate locator with no discovery of any kind and a visible configuration failure (rc 2) when it cannot be used (D3/D3.1), an unexpected Rust child status mapped to the public internal-error path with the raw status retained in a typed `child_exit_code` at report schema 2 (D5), and a launcher-seam compare mode that extracts once, proves both engines received byte-identical input, and refuses to answer — public exit 5 with reproduction evidence — when they diverge or either fails (D4/D4.1). No silent fallback anywhere: a Rust failure is never a Python success. Engine selection stays outside `own-cli` (C-4). Compare is a development/CI seam, not yet a promised public feature. Counts are generated (`docs/generated/p022-stage1-mutations.md`). #263's baselines are the evidence prerequisite of the cutover decision, not a normative blocker. Launcher rulings recorded in #262: engine selection is the launcher's, never the executable's; an unexpected Rust child exit code outside the legal set takes the public internal-error path with the raw child status retained in the evidence; no silent fallback | +| 8 | Rust-default **cutover**, rollback gate, Python distribution removal | #262 | **Stage 1 landed** (opt-in Rust behind the launcher). **Stage 2 landed** (Own.NET's own CI and dogfood select Rust; the public contract unmoved). **STAGE 3 LANDED: RUST IS THE PUBLIC DEFAULT.** All four launcher surfaces moved together and were each DRIVEN to prove it rather than read: `owen` (`EngineSelection.Default`), `own-check.sh`, `own-check.ps1` and the Action. What makes the default reachable is **D6** — the `own-cli` binary now ships INSIDE the Owen.Cli package, resolved from `AppContext.BaseDirectory` by platform key, which is packaged resolution and not discovery: one computed path, no PATH lookup, no `rust/target` probing, and a visible exit-2 configuration error when it is absent. `OWEN_RUST_CORE` keeps Stage 1's meaning and its precedence; only an ABSENT variable reaches the packaged path. Measured on a real packed artifact installed from an isolated feed: a bare `owen check` finds OWN001 with **no Python of any spelling on PATH**, and `--engine python` on the same install finds the same OWN001. A NuGet zip does not carry Unix modes, so a payload binary that arrives mode 644 is materialised into `~/.owen/rust-core//` and made executable — the shape `CoreVendor` already uses, verified by chmod-ing the installed binary. **Python is the tested ROLLBACK, not a fallback:** `tests/test_stage3_rollback.py` drives four states that must stay distinct (default→Rust; explicit python agrees; broken candidate with nothing asked → visible exit 2 that denies a fallback in as many words; broken candidate with python asked → python runs anyway), because a launcher with a hidden fallback passes any test that only ever asks for Python on purpose. Agreement is measured as the VERDICT rather than the bytes, since #262's Windows ruling declares the reference's cp1252/CRLF output a behaviour change and does NOT claim byte parity with it — a byte comparison here asserted exactly that claim and could only fail on Windows, which it did. Before the flip, the three Python-first hygiene tails #262 owed were closed IN THE REFERENCE FIRST: invalid UTF-8 (rc 70 → rc 2, now byte parity because the message stopped speaking in a decoder's voice), V1's non-standard constants (moved to the JSON door), and V2's top-level `-0` — which until this change was **accepted as v0 and analysed** by the public default engine. V2's residual difference is CLI-B2, a NARROWING of the ratified boundary from accept-versus-reject down to one token's spelling inside a refusal both sides now make. The differential evidence was RE-TAKEN on the candidate tree rather than reused: fast compare 104/104, C# samples 1/1, and the #260 sweep's ten documents over six targets all agreed with **0 acceptance-unexplained** — the #260 record was contractually reusable by its ancestor rule but predates both #261's strict-door change and this branch's input-boundary repair, and a cutover is the wrong place to spend that allowance. **Performance is DEFERRED BY OWNER for this decision and Stage 3 makes no performance claim; #263 remains open and remains the baseline tracker.** The packet is generated from a ledger, never typed (`docs/generated/p022-stage3-packet.md`), and its three performance fields read `DEFERRED BY OWNER — NOT MEASURED` under a control that refuses to let a deferral soften into a claim. **Stage 4 — removing Python from the distribution — is NOT started and is a separate, separately reviewable PR.** The record is [the decision note](../notes/p022-stage3-cutover-decision.md) and [the rollback contract](../notes/owen-engine-rollback.md) | -**Preferred queue:** **#262 is in progress — Stage 1 (opt-in Rust behind the launcher) and Stage 2 (Own.NET's own CI and dogfood on Rust; the public default unmoved) have landed; Stage 3, the public cutover, is next and needs its own authorization.** #261's production OwnIR executable is +**Preferred queue:** **#262's Stage 3 has landed — Rust is the public default on every launcher surface, Python is the tested rollback, and Stage 4 (Python distribution removal) has not started.** P-022 is therefore NOT complete: Stage 4 is open, #263 is open and still owes the performance baselines this decision explicitly deferred, and the compare gates are retained as development/CI evidence. Decision established on the qualification candidate **f1d3681**, 31/31 green ([35312190310](https://github.com/PhysShell/Own.NET/actions/runs/35312190310)); implementation candidate **b225436** re-qualified 31/31 ([35320877147](https://github.com/PhysShell/Own.NET/actions/runs/35320877147)) after a repository-hygiene commit untracked the Cargo build output under `rust/target-fault` (build output, never evidence); the terminal merge candidate is the reconciliation-only head qualified by CI as itself, with its canonical SHA and run recorded on #262 — a commit cannot name its own SHA. With the cutover complete the P-022 feature freeze on verdict-changing inference lifts, which moves the **P-037 A1** production gate from BLOCKED BY DESIGN to **UNBLOCKED** — not started here, and deliberately not started in the same change as a cutover: A1 begins on a new branch from the Rust-default baseline so the provenance line stays legible. built and replaying on both platforms (row 7b), and #261 is closed completed (2026-09-08, PR #347), so the queue has moved past it. In parallel and off the critical chain: #257, #263 (the evidence prerequisite of #262's decision), #345 — the residual `.own`/dev diff --git a/docs/proposals/README.md b/docs/proposals/README.md index d189d5d2..e3c9a1e0 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -41,7 +41,7 @@ proposal is marked `done` with a pointer. | [P-017](P-017-multi-stack-frontends.md) | Multi-stack frontends (OwnTS / OwnJVM: OwnJava + OwnKotlin) | draft | | [P-020](P-020-ownts-react-effects.md) | OwnTS React effects profile (`Own.React`) — the effect-storm angle | draft | | [P-021](P-021-async-audit-pack.md) | Async audit pack (`Own.Async`) | draft | -| [P-022](P-022-rust-core-migration.md) | Rust core migration: crate DAG, patterns, prior art, differential oracle (Python = golden) | in execution — steps 0–4 built (#214/#249); step 5a done (full diagnostic contract, #255 via #319/#320/#321); step 5b SARIF done (#256; `.ownreport.json` struck — a buffer report needing the AST, not a diagnostics surface); step 6a done (`spec/Bridge.md`, #258); step 6b complete at final acceptance (`own-lowered`/`own-bridge`, #259: lowering and MOS parity landed; strict-door validation complete with no known divergence — the first 0/0/0 proved to be the ledger agreeing with its own author, and the second omitted two families that a Python-first defensive-limit change (#326) had to close before the third could measure them; analysis wiring complete at the checkpoint-4 surface — `check_facts` through the real analyses, Layer 3 goldens built, with an executable exclusion ledger naming each declared boundary; **cp5 complete at its surface** — the replay compares EVERY `Finding` member (the BR-V4 wording matrix and the BR-V5 evidence slices included) and every refusal in full, and a second fixture family freezes the BR-V9 rendered surfaces byte for byte, all against goldens none of which was regenerated; **row 4b complete** — the obligation-protocol analysis (OBL001–005) is ported into `own-analysis`, its typed values come from the ONE grammar in `own-ir` that the strict door already delegated to, an analysis-level fact-parity family freezes every violation member with zero Python, the bridge maps BR-P3 in its BR-V1 place, and both protocol documents are promoted out of the exclusion ledger without regenerating either golden; **#259 final acceptance reached** — the last thing it owed was the coordinate-domain decision, and that landed Python-first: `spec/OwnIR.md` §4.2 bounds every `line` to `[0, 2147483647]` and every `column` to `[1, 2147483647]` (int32 is the line type of every consumer this project feeds; `0` stays legal as the reference's own absent sentinel), every line-bearing field is validated including the two §4.2 recorded as checked nowhere, the tolerant door degrades an out-of-domain coordinate rather than clamping it, the Rust door and bridge mirror all of it, and the four `verdict_boundary_*` controls are promoted out of the exclusion ledger — which now names only the two #294 OD-1 door controls, a declared boundary rather than open work. Not shadow mode, which is #260's acceptance. Every count is generated: `docs/generated/p022-cp1-census.md`, `docs/generated/p022-cp4-census.md`, `docs/generated/p022-coord-census.md`, `docs/generated/p022-cp5-inventory.md`, `docs/generated/p022-cp4b-mutations.md` and `docs/generated/p022-coord-mutations.md`); step 7a shadow-mode INFRASTRUCTURE complete (checkpoints 1–4: `ownlang/repro.py` + `own-shadow` — canonical same-input `OwnIR` identity, the reproduction-artifact format, the engine protocol, the `AnalysisTrace` (#269) with stable-ID normalization, first-divergence reduction), and #260's **acceptance decisions landed over the committed corpus**: the verdict layer is in reduction scope (the scope IS the layer order), acceptance is a field of its own beside the observation kind under a frozen `(layer, kind, class)` boundary policy the refusing engine declares structurally, canonical SARIF is compared as a DERIVED surface rather than a layer, artifact v3 attests the raw input and each engine's `consumed` (so the byte-level same-input invariant is proved rather than approximated by canonical identity), and a dev-only `own-shadow-engine` adapter plus a compare driver run the two engines over one byte sequence in CI. **#260's final acceptance is REACHED**: compare mode reports zero acceptance-unexplained over its full test matrix — the committed corpus, the C# samples, the `examples/` tree, the five pinned OSS repositories of #243 at their verified pins and the large-solution controls — at all three layers and on the derived SARIF, on byte-attested same input, with the two #294 OD-1 typed-door boundaries declared by policy. The sweep is ten documents over six targets, each extracted exactly once through `own-check.sh --emit-facts` and compared from those bytes; a repository is not covered because its extraction succeeded, so a run that compared zero documents fails, a declared target nothing reached fails, and the denominators are recorded per target. Taking the measurement found six harness defects and no engine divergence. Still **not** shadow mode achieved, **not** "P-022 done" and **not** "Rust is the default" — that is #262's cutover behind #261; a crash is never a fallback, Python stays the public engine, and no production behaviour changed. Every count is generated (`docs/generated/p022-shadow-sweep.md`, `docs/generated/p022-shadow-census.md`, `docs/generated/p022-shadow-mutations.md`), the decisions are recorded verbatim in [the owner-decision ledger](../notes/p022-shadow-infra-owner-decisions.md), and the records are [the sweep note](../notes/p022-shadow-sweep.md) and [the acceptance note](../notes/p022-shadow-acceptance.md); **step 7b 261.A ratified and 261.B built** — #261's production Rust OwnIR executable `own-cli ownir` exists behind the unchanged `owen` launcher and reproduces the reference's `ownir` contract (argument handling, display policy, stream separation, the four formats and every exit code) over a frozen CLI fixture replayed with zero Python on Linux and Windows CI; the top-level shell follows the `owen` convention as a parity surface of its own and everything after `ownir` is the reference's own behaviour, measured; a catchable panic is one actionable message and exit 70 and an uncatchable death a visible hard failure, both measured under an off-by-default `fault-injection` feature. Owner decisions C-1..C-5 were applied, not re-litigated. 261.B is built and replaying on both platforms, with the four rulings settled as follows: (2a) the `ownir_version` Version messages are **byte-parity** — that text is ours on both sides, so the divergence was a Rust bug and was fixed rather than declared, and a second repair pass re-took the measurement over value **classes** rather than four hand-picked values, fixing three more defects a single-key integer control could not reach plus a round-half-to-even tie found by a 200 000-double sweep (24/24 classes and 20 000 randomized documents byte-identical, with V1/V2/V4 declared and excluded); (2b) the JSON parser detail is a **declared typed boundary, CLI-B1** — the CLI-owned wrapper `{path}: error: {path} is not valid JSON: ` is pinned byte-exact and only the parser library's own text after it is declared, guarded by an executable `kind == Json` proof and a negative control that runs ONE case against two byte sequences so eligibility can only turn on the facts bytes, and a Json rejection that loses its internal prefix fails onto rc 70 rather than passing through; (1) invalid UTF-8 is a **declared defect of the Python reference**, excluded from the byte contract pending a Python-first hygiene tail (`UnicodeDecodeError` -> `OwnIRError` -> rc 2) to close before public cutover, recorded in #262 — the tracker of record — and mirrored in #250's Still missing list; (3) Windows is **A** canonical reference parity plus **B** Rust portability, with **C** native-Windows Python parity explicitly **NOT claimed** — the reference there emits cp1252/CRLF and can fail with `UnicodeEncodeError`, recorded in #262 — the tracker of record — as a behavior change rather than parity, and mirrored in #250's Still missing list. Every count is generated (`docs/generated/p022-cli-census.md`, `docs/generated/p022-cli-mutations.md`), the record is [the note](../notes/p022-cli-ownir.md). Nothing was wired, published or defaulted by #261 — that is #262, and #261 is closed completed (2026-09-08, PR #347); **step 8 (#262) is in progress: Stage 1 landed, making the Rust core opt-in behind the existing launcher, and Stage 2 landed, putting Own.NET's OWN CI and dogfood on Rust by default with the public contract unmoved** via one explicit `--engine python|rust|compare` selector on all four launcher surfaces, the ratified `OWEN_RUST_CORE` candidate locator (no discovery; an unusable locator is a configuration failure, never a fallback), an unexpected Rust child status mapped to the public internal-error path with the raw status retained in a typed `child_exit_code`, and a launcher-seam compare mode that extracts once and refuses to answer when the engines disagree. Python remains the default and the reference, nothing public defaults to Rust, and Python distribution is untouched. Stage 2 changes which engine THIS repository runs internally and nothing else: every dogfood call site selects Rust explicitly through OWEN_RUST_CORE with the production own-cli, a committed census classifies every CI call site that executes the core so the claim has a denominator, and the compare gates and reference paths are untouched. Neither stage is the cutover, which stays behind Gate G3 and Stage 3's own authorization. Its #261 prerequisite is satisfied, with #263's baselines as the evidence prerequisite of its decision | +| [P-022](P-022-rust-core-migration.md) | Rust core migration: crate DAG, patterns, prior art, differential oracle (Python = golden) | in execution — steps 0–4 built (#214/#249); step 5a done (full diagnostic contract, #255 via #319/#320/#321); step 5b SARIF done (#256; `.ownreport.json` struck — a buffer report needing the AST, not a diagnostics surface); step 6a done (`spec/Bridge.md`, #258); step 6b complete at final acceptance (`own-lowered`/`own-bridge`, #259: lowering and MOS parity landed; strict-door validation complete with no known divergence — the first 0/0/0 proved to be the ledger agreeing with its own author, and the second omitted two families that a Python-first defensive-limit change (#326) had to close before the third could measure them; analysis wiring complete at the checkpoint-4 surface — `check_facts` through the real analyses, Layer 3 goldens built, with an executable exclusion ledger naming each declared boundary; **cp5 complete at its surface** — the replay compares EVERY `Finding` member (the BR-V4 wording matrix and the BR-V5 evidence slices included) and every refusal in full, and a second fixture family freezes the BR-V9 rendered surfaces byte for byte, all against goldens none of which was regenerated; **row 4b complete** — the obligation-protocol analysis (OBL001–005) is ported into `own-analysis`, its typed values come from the ONE grammar in `own-ir` that the strict door already delegated to, an analysis-level fact-parity family freezes every violation member with zero Python, the bridge maps BR-P3 in its BR-V1 place, and both protocol documents are promoted out of the exclusion ledger without regenerating either golden; **#259 final acceptance reached** — the last thing it owed was the coordinate-domain decision, and that landed Python-first: `spec/OwnIR.md` §4.2 bounds every `line` to `[0, 2147483647]` and every `column` to `[1, 2147483647]` (int32 is the line type of every consumer this project feeds; `0` stays legal as the reference's own absent sentinel), every line-bearing field is validated including the two §4.2 recorded as checked nowhere, the tolerant door degrades an out-of-domain coordinate rather than clamping it, the Rust door and bridge mirror all of it, and the four `verdict_boundary_*` controls are promoted out of the exclusion ledger — which now names only the two #294 OD-1 door controls, a declared boundary rather than open work. Not shadow mode, which is #260's acceptance. Every count is generated: `docs/generated/p022-cp1-census.md`, `docs/generated/p022-cp4-census.md`, `docs/generated/p022-coord-census.md`, `docs/generated/p022-cp5-inventory.md`, `docs/generated/p022-cp4b-mutations.md` and `docs/generated/p022-coord-mutations.md`); step 7a shadow-mode INFRASTRUCTURE complete (checkpoints 1–4: `ownlang/repro.py` + `own-shadow` — canonical same-input `OwnIR` identity, the reproduction-artifact format, the engine protocol, the `AnalysisTrace` (#269) with stable-ID normalization, first-divergence reduction), and #260's **acceptance decisions landed over the committed corpus**: the verdict layer is in reduction scope (the scope IS the layer order), acceptance is a field of its own beside the observation kind under a frozen `(layer, kind, class)` boundary policy the refusing engine declares structurally, canonical SARIF is compared as a DERIVED surface rather than a layer, artifact v3 attests the raw input and each engine's `consumed` (so the byte-level same-input invariant is proved rather than approximated by canonical identity), and a dev-only `own-shadow-engine` adapter plus a compare driver run the two engines over one byte sequence in CI. **#260's final acceptance is REACHED**: compare mode reports zero acceptance-unexplained over its full test matrix — the committed corpus, the C# samples, the `examples/` tree, the five pinned OSS repositories of #243 at their verified pins and the large-solution controls — at all three layers and on the derived SARIF, on byte-attested same input, with the two #294 OD-1 typed-door boundaries declared by policy. The sweep is ten documents over six targets, each extracted exactly once through `own-check.sh --emit-facts` and compared from those bytes; a repository is not covered because its extraction succeeded, so a run that compared zero documents fails, a declared target nothing reached fails, and the denominators are recorded per target. Taking the measurement found six harness defects and no engine divergence. Still **not** shadow mode achieved, **not** "P-022 done" and **not** "Rust is the default" — that is #262's cutover behind #261; a crash is never a fallback, Python stays the public engine, and no production behaviour changed. Every count is generated (`docs/generated/p022-shadow-sweep.md`, `docs/generated/p022-shadow-census.md`, `docs/generated/p022-shadow-mutations.md`), the decisions are recorded verbatim in [the owner-decision ledger](../notes/p022-shadow-infra-owner-decisions.md), and the records are [the sweep note](../notes/p022-shadow-sweep.md) and [the acceptance note](../notes/p022-shadow-acceptance.md); **step 7b 261.A ratified and 261.B built** — #261's production Rust OwnIR executable `own-cli ownir` exists behind the unchanged `owen` launcher and reproduces the reference's `ownir` contract (argument handling, display policy, stream separation, the four formats and every exit code) over a frozen CLI fixture replayed with zero Python on Linux and Windows CI; the top-level shell follows the `owen` convention as a parity surface of its own and everything after `ownir` is the reference's own behaviour, measured; a catchable panic is one actionable message and exit 70 and an uncatchable death a visible hard failure, both measured under an off-by-default `fault-injection` feature. Owner decisions C-1..C-5 were applied, not re-litigated. 261.B is built and replaying on both platforms, with the four rulings settled as follows: (2a) the `ownir_version` Version messages are **byte-parity** — that text is ours on both sides, so the divergence was a Rust bug and was fixed rather than declared, and a second repair pass re-took the measurement over value **classes** rather than four hand-picked values, fixing three more defects a single-key integer control could not reach plus a round-half-to-even tie found by a 200 000-double sweep (24/24 classes and 20 000 randomized documents byte-identical, with V1/V2/V4 declared and excluded); (2b) the JSON parser detail is a **declared typed boundary, CLI-B1** — the CLI-owned wrapper `{path}: error: {path} is not valid JSON: ` is pinned byte-exact and only the parser library's own text after it is declared, guarded by an executable `kind == Json` proof and a negative control that runs ONE case against two byte sequences so eligibility can only turn on the facts bytes, and a Json rejection that loses its internal prefix fails onto rc 70 rather than passing through; (1) invalid UTF-8 is a **declared defect of the Python reference**, excluded from the byte contract pending a Python-first hygiene tail (`UnicodeDecodeError` -> `OwnIRError` -> rc 2) to close before public cutover, recorded in #262 — the tracker of record — and mirrored in #250's Still missing list; (3) Windows is **A** canonical reference parity plus **B** Rust portability, with **C** native-Windows Python parity explicitly **NOT claimed** — the reference there emits cp1252/CRLF and can fail with `UnicodeEncodeError`, recorded in #262 — the tracker of record — as a behavior change rather than parity, and mirrored in #250's Still missing list. Every count is generated (`docs/generated/p022-cli-census.md`, `docs/generated/p022-cli-mutations.md`), the record is [the note](../notes/p022-cli-ownir.md). Nothing was wired, published or defaulted by #261 — that is #262, and #261 is closed completed (2026-09-08, PR #347); **step 8 (#262): Stage 1, Stage 2 and STAGE 3 have all landed — the Rust core is the PUBLIC DEFAULT engine.** Stage 1 made it opt-in behind the launcher; Stage 2 put Own.NET's own CI and dogfood on it with the public contract unmoved; Stage 3 moved the public default itself, on all four launcher surfaces together (`owen`, `own-check.sh`, `own-check.ps1`, the Action), each driven to prove it rather than read. D6 makes that default reachable: the `own-cli` binary ships inside the Owen.Cli package and is resolved from the tool's own payload by platform key — packaged resolution, not discovery (one computed path, no PATH lookup, no `rust/target` probing, a visible exit-2 configuration error when absent), with `OWEN_RUST_CORE` keeping Stage 1's meaning and precedence and only an ABSENT variable reaching the packaged path. Measured on a real packed artifact installed from an isolated feed: a bare `owen check` finds OWN001 with no Python on PATH at all, and `--engine python` on the same install finds the same OWN001. Python is the tested ROLLBACK and never a fallback — four states are driven apart and must stay distinct, including the pair one flag apart that a hidden fallback would collapse. The three Python-first hygiene tails #262 owed were closed in the REFERENCE first before the flip (invalid UTF-8 rc 70 → rc 2 and now byte parity; V1 moved to the JSON door; V2's top-level `-0`, which until then was accepted as v0 and analysed, now refused — narrowing that ratified boundary to CLI-B2). The differential evidence was RE-TAKEN on the candidate tree rather than reused: 104/104 fast compare, 1/1 samples, and the #260 sweep's ten documents over six targets all agreed with 0 acceptance-unexplained. **Performance is DEFERRED BY OWNER for the Stage-3 decision — Stage 3 makes no performance claim and #263 remains open as the baseline tracker.** **Stage 4 (Python distribution removal) is NOT started**, so P-022 is not complete; the record is [the decision note](../notes/p022-stage3-cutover-decision.md), [the rollback contract](../notes/owen-engine-rollback.md) and the generated packet `docs/generated/p022-stage3-packet.md` | | [P-023](P-023-architecture-guard.md) | Architecture guard (`Own.Arch`): rules.yaml intent model + dependency-graph gate + baseline ratchet | draft | | [P-024](P-024-security-audit-profile.md) | Security audit profile (external tools + SARIF adapters; rejects own scanner engine) | draft | | [P-025](P-025-obligation-protocols.md) | Obligation protocols (`Own.Protocols`): barrier-sensitive project invariants (OBL001–005) | first slice built (core + bridge + fixtures; extractor pending) | diff --git a/frontend/roslyn/OwnSharp.Cli/EngineSelection.cs b/frontend/roslyn/OwnSharp.Cli/EngineSelection.cs index 5ae8f130..ad30d037 100644 --- a/frontend/roslyn/OwnSharp.Cli/EngineSelection.cs +++ b/frontend/roslyn/OwnSharp.Cli/EngineSelection.cs @@ -8,10 +8,12 @@ namespace OwnSharp.Cli; /// internal enum Engine { - /// The vendored Python core. Stage-1 default and the reference. + /// The vendored Python core. The reference implementation, and the + /// explicit rollback from Stage 3 (#262) — never a fallback. Python, - /// The Rust core (`own-cli ownir`), opt-in at Stage 1. + /// The Rust core (`own-cli ownir`). Opt-in at Stage 1, Own.NET's + /// own default at Stage 2, the PUBLIC default from Stage 3. Rust, /// Both engines over one captured input, compared (D4/D4.1). @@ -21,17 +23,37 @@ internal enum Engine /// /// Parsing and the shared exit-code contract for the selected engine. /// -/// Python is the Stage-1 default (D1) and this type is where that -/// is written down once: is the single place a reader — -/// or a mutation — can move it, which is what makes the "the default silently -/// became Rust" control load-bearing rather than a matter of reading four -/// launchers and hoping. +/// Rust is the default from Stage 3 (#262) and this type is where +/// that is written down once: is the single place a +/// reader — or a mutation — can move it, which is what makes the control on it +/// load-bearing rather than a matter of reading four launchers and hoping. +/// +/// Python is not gone and is not hidden. It is the tested ROLLBACK and it +/// remains the reference: --engine python selects it explicitly on every +/// surface, and it keeps working for the whole observation window. What Stage 3 +/// changed is which engine answers when nobody says. Stage 4 — removing Python +/// from the distribution — is a separate, separately reviewable change and none +/// of it happens here. /// internal static class EngineSelection { - /// D1: Python remains the default for the whole of Stage 1. The - /// public default does not move before Gate G3 (#262 Stage 3). - public const Engine Default = Engine.Python; + /// + /// #262 Stage 3: the public default is Rust. + /// + /// This constant moved from at the + /// cutover, and it is the whole cutover as far as this surface is + /// concerned — which is the point of having written it down in one place at + /// Stage 1 rather than in four. The other three launcher surfaces + /// (own-check.sh, own-check.ps1, the Action) carry the same default in + /// their own idiom and are held to it together. + /// + /// What does NOT follow from this line: a Rust failure never becomes + /// a Python success. There is no fallback here, automatic or otherwise. A + /// candidate that cannot be resolved or cannot be started is a visible + /// configuration error (exit 2), and selecting Python is something a caller + /// does on purpose. + /// + public const Engine Default = Engine.Rust; /// The spellings accepted by every launcher surface. One contract /// across `owen`, own-check.sh, own-check.ps1 and the Action (D2). diff --git a/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj index ea5991d2..81cfce2b 100644 --- a/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj +++ b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj @@ -78,6 +78,62 @@ + + + + + + + + + + + + \n" + "# P-022 Stage 3 — #262 cutover decision packet\n\n" + "```text\n" + body + "\n```\n\n" + "> **Performance.** " + led["owner_ruling_performance"] + "\n" + f"\nCLI contract campaign: {cli_line}\n" + + section("Known differences", led["known_differences"], "status", "summary") + + section("Closed by this change (recorded as closures, not as differences)", + led["closed_in_this_change"], "was", "now") + + owed_block + + "\n## Measurements\n\n" + + "\n".join(f"* `{m['id']}` [{m['platform']}] **{m['label']}** — {m['result']}" + for m in led["measurements"]) + "\n" + ) + + +def run() -> int: + text = packet() + if "--write" in sys.argv: + os.makedirs(os.path.dirname(OUT), exist_ok=True) + with open(OUT, "w", encoding="utf-8") as f: + f.write(text) + print(f"wrote {os.path.relpath(OUT, ROOT)}") + else: + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/tests/fixtures/cli_ownir/inputs/not_utf8.facts.broken b/tests/fixtures/cli_ownir/inputs/not_utf8.facts.broken new file mode 100644 index 00000000..45bf9e39 --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/not_utf8.facts.broken @@ -0,0 +1 @@ +{"ownir_version": 0, "components": [{"name": "ÿþ"}]} \ No newline at end of file diff --git a/tests/fixtures/cli_ownir/inputs/version_infinity.facts.broken b/tests/fixtures/cli_ownir/inputs/version_infinity.facts.broken new file mode 100644 index 00000000..09985ede --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/version_infinity.facts.broken @@ -0,0 +1 @@ +{"ownir_version": Infinity} \ No newline at end of file diff --git a/tests/fixtures/cli_ownir/inputs/version_nan.facts.broken b/tests/fixtures/cli_ownir/inputs/version_nan.facts.broken new file mode 100644 index 00000000..52418857 --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/version_nan.facts.broken @@ -0,0 +1 @@ +{"ownir_version": NaN} \ No newline at end of file diff --git a/tests/fixtures/cli_ownir/inputs/version_negative_infinity.facts.broken b/tests/fixtures/cli_ownir/inputs/version_negative_infinity.facts.broken new file mode 100644 index 00000000..a34fdd5a --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/version_negative_infinity.facts.broken @@ -0,0 +1 @@ +{"ownir_version": -Infinity} \ No newline at end of file diff --git a/tests/fixtures/cli_ownir/inputs/version_negative_zero.facts.json b/tests/fixtures/cli_ownir/inputs/version_negative_zero.facts.json new file mode 100644 index 00000000..d83b2b20 --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/version_negative_zero.facts.json @@ -0,0 +1 @@ +{"ownir_version": -0} diff --git a/tests/fixtures/cli_ownir/inputs/version_negative_zero_nested.facts.json b/tests/fixtures/cli_ownir/inputs/version_negative_zero_nested.facts.json new file mode 100644 index 00000000..77d7ca15 --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/version_negative_zero_nested.facts.json @@ -0,0 +1 @@ +{"ownir_version": [-0]} diff --git a/tests/fixtures/cli_ownir/manifest.json b/tests/fixtures/cli_ownir/manifest.json index d044ebd6..9ac47de0 100644 --- a/tests/fixtures/cli_ownir/manifest.json +++ b/tests/fixtures/cli_ownir/manifest.json @@ -13,6 +13,14 @@ "pinned": "exit 2, stderr, kind == Json, and the full CLI-owned wrapper byte-exact: '{path}: error: {path} is not valid JSON: '", "declared": "only the bytes AFTER that prefix — the parser library's own text", "guard": "rust/crates/own-cli/tests/replay.rs proves valid UTF-8, then OwnIr::from_json rejecting, then kind == Json, BEFORE relaxing anything" + }, + "CLI-B2": { + "name": "TOP_LEVEL_NEGATIVE_ZERO", + "applies_iff": "the facts are exactly the top-level scalar `ownir_version: -0`, which the two supported parsers read as different values (#262 V2)", + "pinned": "exit 2, stderr, kind == Version, and the shared refusal opening byte-exact: '{path}: error: OwnIR 'ownir_version' must be an integer, got '", + "declared": "only the bytes AFTER that opening — how each side spells the token it refused (`-0` in the reference, `-0.0` in the port)", + "guard": "rust/crates/own-cli/tests/replay.rs proves valid UTF-8, then OwnIr::from_json rejecting, then kind == Version, BEFORE relaxing anything; and `refuse-version-negative-zero-nested` holds the scope by staying byte-exact with no boundary", + "narrowed_by": "#262 Stage 3 — this was an ACCEPT-versus-REJECT divergence (the reference accepted the document as v0 and analysed it) until the Python-first hygiene tail closed; it is now a one-token spelling difference inside a refusal both sides make" } }, "cases": [ @@ -396,6 +404,92 @@ "expected_kind": "json" } }, + { + "name": "refuse-not-utf8", + "oracle": "python", + "rules": [ + "strict-door", + "utf8-byte-parity" + ], + "pins": [ + "an undecodable facts file is refused as INPUT (exit 2), never reported as an analyzer bug (exit 70); and the offending byte and its offset are byte-identical in both implementations" + ] + }, + { + "name": "refuse-json-nan", + "oracle": "python", + "rules": [ + "strict-door", + "cli-b1-json-parser-detail", + "v1-non-standard-constants" + ], + "pins": [ + "NaN is refused at the JSON door, not accepted and then described as a float by the Version door" + ], + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } + }, + { + "name": "refuse-json-infinity", + "oracle": "python", + "rules": [ + "strict-door", + "cli-b1-json-parser-detail", + "v1-non-standard-constants" + ], + "pins": [ + "Infinity: the same refusal, the same door" + ], + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } + }, + { + "name": "refuse-json-negative-infinity", + "oracle": "python", + "rules": [ + "strict-door", + "cli-b1-json-parser-detail", + "v1-non-standard-constants" + ], + "pins": [ + "-Infinity: the third constant, enumerated rather than assumed to follow from the other two" + ], + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } + }, + { + "name": "refuse-version-negative-zero", + "oracle": "python", + "rules": [ + "strict-door", + "v2-top-level-negative-zero" + ], + "pins": [ + "the literal top-level -0 is REFUSED at the OwnIR input boundary; it used to be accepted as v0 and analysed" + ], + "boundary": { + "id": "CLI-B2", + "expected_kind": "version" + } + }, + { + "name": "refuse-version-negative-zero-nested", + "oracle": "python", + "rules": [ + "strict-door", + "version-byte-parity", + "v2-top-level-negative-zero" + ], + "pins": [ + "a -0 BELOW the top level is not V2: both sides render `got [0]`, byte for byte, exactly as before" + ] + }, { "name": "stdin-dash-is-out-of-contract", "oracle": "python", diff --git a/tests/fixtures/cli_ownir/refuse-json-infinity.case.json b/tests/fixtures/cli_ownir/refuse-json-infinity.case.json new file mode 100644 index 00000000..5065d05c --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-json-infinity.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/version_infinity.facts.broken" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/version_infinity.facts.broken: error: inputs/version_infinity.facts.broken is not valid JSON: Infinity is not a JSON value (RFC 8259 has no NaN, Infinity or -Infinity)\n", + "os_error_tail": null + }, + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } +} diff --git a/tests/fixtures/cli_ownir/refuse-json-nan.case.json b/tests/fixtures/cli_ownir/refuse-json-nan.case.json new file mode 100644 index 00000000..92f81188 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-json-nan.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/version_nan.facts.broken" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/version_nan.facts.broken: error: inputs/version_nan.facts.broken is not valid JSON: NaN is not a JSON value (RFC 8259 has no NaN, Infinity or -Infinity)\n", + "os_error_tail": null + }, + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } +} diff --git a/tests/fixtures/cli_ownir/refuse-json-negative-infinity.case.json b/tests/fixtures/cli_ownir/refuse-json-negative-infinity.case.json new file mode 100644 index 00000000..b0805931 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-json-negative-infinity.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/version_negative_infinity.facts.broken" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/version_negative_infinity.facts.broken: error: inputs/version_negative_infinity.facts.broken is not valid JSON: -Infinity is not a JSON value (RFC 8259 has no NaN, Infinity or -Infinity)\n", + "os_error_tail": null + }, + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } +} diff --git a/tests/fixtures/cli_ownir/refuse-not-utf8.case.json b/tests/fixtures/cli_ownir/refuse-not-utf8.case.json new file mode 100644 index 00000000..4fa76c02 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-not-utf8.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/not_utf8.facts.broken" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/not_utf8.facts.broken: error: inputs/not_utf8.facts.broken is not valid UTF-8: byte 0xff at offset 46\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/refuse-version-negative-zero-nested.case.json b/tests/fixtures/cli_ownir/refuse-version-negative-zero-nested.case.json new file mode 100644 index 00000000..69f2d4e6 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-version-negative-zero-nested.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/version_negative_zero_nested.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/version_negative_zero_nested.facts.json: error: OwnIR 'ownir_version' must be an integer, got [0]\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/refuse-version-negative-zero.case.json b/tests/fixtures/cli_ownir/refuse-version-negative-zero.case.json new file mode 100644 index 00000000..3e94b825 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-version-negative-zero.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/version_negative_zero.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/version_negative_zero.facts.json: error: OwnIR 'ownir_version' must be an integer, got -0: the literal -0 is refused at the OwnIR input boundary because the two supported parsers do not agree on what it means (this reference reads it as the integer 0, serde_json as the float -0.0). Write 0.\n", + "os_error_tail": null + }, + "boundary": { + "id": "CLI-B2", + "expected_kind": "version" + } +} diff --git a/tests/test_checkpoint_status.py b/tests/test_checkpoint_status.py index 941b2473..0eba447a 100644 --- a/tests/test_checkpoint_status.py +++ b/tests/test_checkpoint_status.py @@ -61,16 +61,26 @@ SHADOW_CENSUS_MD, SHADOW_MUTATIONS_MD, SHADOW_SWEEP_MD, + STAGE1_CAMPAIGNS, + STAGE2_CAMPAIGNS, check, ) EVIDENCE = os.path.join(ROOT, "docs", "evidence") # Every campaign definition in the tree, gated for replayability. A campaign # nobody listed is a campaign nobody re-anchors. +# The Stage-1 and Stage-2 campaigns were NOT in this list until #262 Stage 3, +# and their absence is exactly why the cutover's drift into them stayed hidden +# until a Windows job that actually runs one went red: three of their mutants +# had turned into no-ops (they mutated Python -> Rust, which the cutover made +# the shipped state) and a fourth had become ambiguous, and nothing in the +# ordinary suite asked. A campaign nobody re-anchors is a campaign nobody +# notices has stopped applying. DEFINITIONS = (CAMPAIGN, *(os.path.join(EVIDENCE, f"{campaign}.json") for _, campaign in (*CP4B_CAMPAIGNS, *CP5_CAMPAIGNS, *SHADOW_CAMPAIGNS, - *COORD_CAMPAIGNS, *CLI_CAMPAIGNS))) + *COORD_CAMPAIGNS, *CLI_CAMPAIGNS, + *STAGE1_CAMPAIGNS, *STAGE2_CAMPAIGNS))) def _anchors() -> list[str]: diff --git a/tests/test_cli_ownir_fixtures.py b/tests/test_cli_ownir_fixtures.py index bfb5bccd..c866827f 100644 --- a/tests/test_cli_ownir_fixtures.py +++ b/tests/test_cli_ownir_fixtures.py @@ -74,6 +74,32 @@ # double as the negative control proving CLI-B1 cannot reach a non-Json kind. CLI_B1 = {"id": "CLI-B1", "expected_kind": "json"} +# The second — and, deliberately, the last — declared boundary. +# +# CLI-B2 TOP_LEVEL_NEGATIVE_ZERO (applies iff the facts are exactly the +# top-level scalar `ownir_version: -0`) +# pinned: exit 2 · stderr · kind == Version · the shared refusal opening, +# byte-exact: "{path}: error: OwnIR 'ownir_version' must be an +# integer, got " +# declared: only the bytes AFTER that opening — how each side SPELLS the +# token it could not accept (`-0` here, `-0.0` in the port) +# +# This is V2 of #262, and it is a boundary the owner had already declared: the +# two supported parsers do not agree on what `-0` MEANS (this reference read it +# as the integer 0, `serde_json` as the float -0.0), and #260 froze the +# ambiguity as a REFUSAL rather than reconciling it. What Stage 3 changed is +# only which side of the ambiguity the reference sits on: it used to ACCEPT the +# document as v0 and now refuses it. So the declared difference has narrowed +# from accept-versus-reject to the spelling of one token inside a refusal both +# sides now make, at the same exit code, at the same door. +# +# It is NOT an extension of CLI-B1 and cannot become one: CLI-B1 applies iff +# the kind is Json and this kind is Version — the very property +# `refuse-version-mismatch` exists to hold. Reconciling the spelling would mean +# teaching one parser the other's reading of `-0`, which is the reconciliation +# #262 ruled out, so it stays declared and stays this narrow. +CLI_B2 = {"id": "CLI-B2", "expected_kind": "version"} + class NonDeterministic(RuntimeError): """Two runs of the reference disagreed. A case like that is not a contract, @@ -184,6 +210,17 @@ def _shell(stdout: str = "", stderr: str = "", exit_code: int = 0) -> dict: _VER_BOOL = "inputs/version_wrong_type_bool.facts.json" _VER_FLOAT = "inputs/version_wrong_type_float.facts.json" _VER_NULL = "inputs/version_wrong_type_null.facts.json" +# The three #262 Stage-3 hygiene tails, each now CLOSED Python-first. The +# non-standard JSON constants and the undecodable file take `.broken` for the +# same reason the other unparseable inputs do, and additionally carry NO +# trailing newline: their contracts are a parser offset and a byte offset, and +# a checkout that rewrote a line ending would move them. +_NOT_UTF8 = "inputs/not_utf8.facts.broken" +_VER_NAN = "inputs/version_nan.facts.broken" +_VER_INFINITY = "inputs/version_infinity.facts.broken" +_VER_NEG_INFINITY = "inputs/version_negative_infinity.facts.broken" +_VER_NEG_ZERO = "inputs/version_negative_zero.facts.json" +_VER_NEG_ZERO_NESTED = "inputs/version_negative_zero_nested.facts.json" class Case: @@ -480,6 +517,78 @@ def _refusal_cases() -> list[Case]: pins=["a UTF-8 BOM: valid UTF-8, invalid JSON — it must reach " "CLI-B1 and not R4's invalid-UTF-8 defect"]), + # --- #262 Stage 3: the three Python-first hygiene tails, CLOSED ----- + # + # Each of these was a recorded defect of THIS reference that #262 owed + # before the public cutover, and each is frozen here so the repair is + # asserted on the PRODUCTION path — the executable a user runs — rather + # than only in a unit helper that calls `load()` directly. + + # Tail 1: invalid UTF-8. It used to escape `load()` as an uncaught + # UnicodeDecodeError and surface as rc 70 ("a bug in the analyzer") + # about a file the user supplied. It is now ordinary refused input, and + # it carries NO boundary on purpose: the reference states the reason as + # a fact about the file rather than in its decoder's voice, so the port + # reports it identically and this is BYTE PARITY. A boundary here would + # be declaring a difference that no longer exists. + Case("refuse-not-utf8", ["ownir", _NOT_UTF8], oracle="python", + rules=["strict-door", "utf8-byte-parity"], + pins=["an undecodable facts file is refused as INPUT (exit 2), " + "never reported as an analyzer bug (exit 70); and the " + "offending byte and its offset are byte-identical in both " + "implementations"]), + + # Tail 2, V1: the non-standard constants. CPython's `json` accepts + # them, so the reference used to reach the VERSION door and describe a + # float (`got nan`) that the source text never contained; `serde_json` + # refuses them at the JSON door. The reference now refuses at the JSON + # door too, which puts both sides at the same exit code, the same door + # and the same rejection KIND — so the only difference left is the + # parser detail CLI-B1 already declares, and no new boundary is needed. + Case("refuse-json-nan", ["ownir", _VER_NAN], oracle="python", + rules=["strict-door", "cli-b1-json-parser-detail", + "v1-non-standard-constants"], + boundary=CLI_B1, + pins=["NaN is refused at the JSON door, not accepted and then " + "described as a float by the Version door"]), + Case("refuse-json-infinity", ["ownir", _VER_INFINITY], + oracle="python", + rules=["strict-door", "cli-b1-json-parser-detail", + "v1-non-standard-constants"], + boundary=CLI_B1, + pins=["Infinity: the same refusal, the same door"]), + Case("refuse-json-negative-infinity", ["ownir", _VER_NEG_INFINITY], + oracle="python", + rules=["strict-door", "cli-b1-json-parser-detail", + "v1-non-standard-constants"], + boundary=CLI_B1, + pins=["-Infinity: the third constant, enumerated rather than " + "assumed to follow from the other two"]), + + # Tail 3, V2: the literal top-level `-0`. The defect was ACCEPTANCE — + # the reference read it as the integer 0 and ANALYSED the document as + # v0. It is refused now, and the residual spelling difference is + # CLI-B2. + Case("refuse-version-negative-zero", ["ownir", _VER_NEG_ZERO], + oracle="python", + rules=["strict-door", "v2-top-level-negative-zero"], + boundary=CLI_B2, + pins=["the literal top-level -0 is REFUSED at the OwnIR input " + "boundary; it used to be accepted as v0 and analysed"]), + # V2's scope control, and the reason it is a separate case rather than + # a sentence in the one above: the ruling is scoped to the TOP-LEVEL + # SCALAR, and a `-0` anywhere else is untouched. #261's Version census + # pinned this as byte parity, and it must STAY byte parity — so this + # case carries no boundary, and a repair that widened to nested values + # would break it here rather than silently. + Case("refuse-version-negative-zero-nested", ["ownir", + _VER_NEG_ZERO_NESTED], + oracle="python", + rules=["strict-door", "version-byte-parity", + "v2-top-level-negative-zero"], + pins=["a -0 BELOW the top level is not V2: both sides render " + "`got [0]`, byte for byte, exactly as before"]), + # The stdin ruling, recorded EXPLICITLY rather than silently: `-` is not # a stdin marker to the reference, it is a file name. Case("stdin-dash-is-out-of-contract", ["ownir", "-"], oracle="python", @@ -664,6 +773,29 @@ def write() -> int: "UTF-8, then OwnIr::from_json rejecting, then " "kind == Json, BEFORE relaxing anything", }, + "CLI-B2": { + "name": "TOP_LEVEL_NEGATIVE_ZERO", + "applies_iff": "the facts are exactly the top-level scalar " + "`ownir_version: -0`, which the two supported " + "parsers read as different values (#262 V2)", + "pinned": "exit 2, stderr, kind == Version, and the shared " + "refusal opening byte-exact: '{path}: error: OwnIR " + "'ownir_version' must be an integer, got '", + "declared": "only the bytes AFTER that opening — how each side " + "spells the token it refused (`-0` in the " + "reference, `-0.0` in the port)", + "guard": "rust/crates/own-cli/tests/replay.rs proves valid " + "UTF-8, then OwnIr::from_json rejecting, then " + "kind == Version, BEFORE relaxing anything; and " + "`refuse-version-negative-zero-nested` holds the " + "scope by staying byte-exact with no boundary", + "narrowed_by": "#262 Stage 3 — this was an ACCEPT-versus-" + "REJECT divergence (the reference accepted the " + "document as v0 and analysed it) until the " + "Python-first hygiene tail closed; it is now a " + "one-token spelling difference inside a refusal " + "both sides make", + }, }, "cases": entries, }) diff --git a/tests/test_ownir_input_boundary.py b/tests/test_ownir_input_boundary.py new file mode 100644 index 00000000..e333359b --- /dev/null +++ b/tests/test_ownir_input_boundary.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""The three #262 Stage-3 cutover hygiene tails, at the OwnIR input boundary. + +`ownlang` is the P-022 REFERENCE. Three of its input-boundary behaviours were +recorded by #262 as defects owed before the public Rust-default cutover, and +they are closed Python-first — the migration's standing rule, because a +divergence repaired by changing the port would freeze the reference's accident +into the contract instead of removing it. + +invalid UTF-8 + before: `UnicodeDecodeError` escaped `load()` uncaught -> rc **70** + after: `OwnIRError` -> rc **2** + +V1, the constants `NaN` / `Infinity` / `-Infinity` + before: accepted by CPython's `json`, then refused downstream by the + VERSION door as a float the source text never contained + after: refused at the **JSON door**, where the port refuses them + +V2, the literal top-level `-0` + before: read as the integer 0 and **ACCEPTED as v0** — the document was + analysed + after: refused at the OwnIR input boundary + +The Rust replay (`rust/crates/own-cli/tests/replay.rs`) proves the port agrees +on all three through the production executable. This module is the other half: +it proves the REFERENCE does what the ruling says, and — the part a byte +comparison cannot express — that each repair stayed inside its ruling's scope. + +Scope is most of the work here, so it is asserted rather than described: + +* V2 is scoped by #262 to the **top-level scalar** `ownir_version`. A `-0` + nested in a wrong-type container, or on any other field, is explicitly NOT + this exception and must keep behaving exactly as before. `#261`'s Version + census pinned `{"ownir_version": [-0]}` as byte parity with the port, so + widening the repair would break a parity claim rather than improve one. +* V2 is scoped to the literal `-0`, not to "a negative zero". `-0.0` and + `-0e0` are floats to both implementations, already agree, and must not be + swept in. +* V3 — an integral version beyond `i64`/`u64` — was already repaired to byte + parity at #261 and is NOT reopened. It shares the `parse_int` path the V2 + repair installs, so it is re-asserted here: a hook that quietly turned a + bignum into something else would break a closed parity claim. + +Run: python tests/test_ownir_input_boundary.py + python tests/run_tests.py (in the suite) +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_HERE) +sys.path.insert(0, _ROOT) + +from ownlang.ownir import OwnIRError, load # noqa: E402 + +# The exit codes the ruling names. 2 is "ordinary refused input"; 70 is the +# analyzer accusing itself of a bug, which is what every tail below used to do +# about a file the caller supplied. +RC_REFUSED = 2 +RC_INTERNAL_ERROR = 70 + + +def _fail(msg: str, *, check: str) -> int: + print(f"FAIL[{check}]: {msg}") + return 1 + + +def _load_bytes(blob: bytes) -> tuple[str, object]: + """Run `load()` over exactly these bytes. Returns ("ok", document) or + ("refused", message).""" + fd, path = tempfile.mkstemp(suffix=".facts.json") + try: + with os.fdopen(fd, "wb") as f: + f.write(blob) + try: + return "ok", load(path) + except OwnIRError as e: + # The message carries the temp path; strip it so the assertions + # below are about the CONTRACT and not about mkstemp. + return "refused", str(e).replace(path, "") + finally: + os.unlink(path) + + +def _run_cli(blob: bytes) -> tuple[int, str]: + """Run the same bytes through the PRODUCTION reference path — the module + entry point a launcher invokes — because a repair that only holds inside + `load()` is not a repair of anything a user can reach.""" + fd, path = tempfile.mkstemp(suffix=".facts.json") + try: + with os.fdopen(fd, "wb") as f: + f.write(blob) + env = dict(os.environ, PYTHONPATH=_ROOT) + proc = subprocess.run( + [sys.executable, "-m", "ownlang", "ownir", path], + capture_output=True, text=True, env=env, cwd=_ROOT) + return proc.returncode, proc.stderr.replace(path, "") + finally: + os.unlink(path) + + +def _refusal_case(blob: bytes, *, check: str, must_contain: str) -> int: + """A document that must be REFUSED as input, both through `load()` and + through the production CLI path, at rc 2 and never at rc 70.""" + failures = 0 + outcome, detail = _load_bytes(blob) + if outcome != "refused": + return _fail(f"load() ACCEPTED {blob!r}; it must raise OwnIRError", + check=check) + if must_contain not in str(detail): + failures += _fail( + f"load() refused {blob!r} but the message does not carry " + f"{must_contain!r}: {detail!r}", check=check) + rc, stderr = _run_cli(blob) + if rc != RC_REFUSED: + failures += _fail( + f"the production path answered {blob!r} with rc {rc}, not " + f"{RC_REFUSED}" + + (" — that is the analyzer calling its own input a bug" + if rc == RC_INTERNAL_ERROR else "") + + f". stderr: {stderr!r}", check=check) + if must_contain not in stderr: + failures += _fail( + f"the production path refused {blob!r} without carrying " + f"{must_contain!r}: {stderr!r}", check=check) + return failures + + +def run() -> int: + failures = 0 + + # --- tail 1: invalid UTF-8 ------------------------------------------- + # + # The message is stated in terms of the FILE — the first offending byte and + # its offset — rather than in the decoder's voice, which is what lets the + # port report it identically. Both halves are asserted, because an offset + # that silently became a character index would still "contain 0xff". + invalid_utf8 = b'{"ownir_version": 0, "components": [{"name": "\xff\xfe"}]}' + assert invalid_utf8.index(b"\xff") == 46 + failures += _refusal_case( + invalid_utf8, check="invalid-utf8", + must_contain="is not valid UTF-8: byte 0xff at offset 46") + + # A truncated multi-byte sequence at the very end: the offset must be the + # START of the incomplete sequence, and the byte must still be reportable. + # This is the case an implementation that indexed past the valid prefix + # would crash on. + failures += _refusal_case( + b'{"ownir_version": 0}\xc3', check="invalid-utf8", + must_contain="is not valid UTF-8: byte 0xc3 at offset 20") + + # SCOPE: valid UTF-8 that is invalid JSON must still reach the JSON door, + # not the new one. A BOM is the case that distinguishes them — it decodes + # cleanly and then fails to parse. + failures += _refusal_case( + b"\xef\xbb\xbf{}", check="invalid-utf8-scope", + must_contain="is not valid JSON") + outcome, detail = _load_bytes(b"\xef\xbb\xbf{}") + if outcome == "refused" and "not valid UTF-8" in str(detail): + failures += _fail( + "a UTF-8 BOM decodes cleanly and must be refused by the JSON " + f"door, not the UTF-8 door: {detail!r}", check="invalid-utf8-scope") + + # --- tail 2, V1: the non-standard constants -------------------------- + # + # Each of the three enumerated, not inferred from one of them: they take + # different routes through CPython's scanner (`-Infinity` in particular is + # reached by the number scanner, not the constant scanner). + for token in (b"NaN", b"Infinity", b"-Infinity"): + failures += _refusal_case( + b'{"ownir_version": ' + token + b"}", check="v1-non-standard-constant", + must_contain=( + f"is not valid JSON: {token.decode()} is not a JSON value")) + # And it must be the JSON door, never the Version door it used to + # reach after CPython had already accepted the token as a float. + outcome, detail = _load_bytes(b'{"ownir_version": ' + token + b"}") + if outcome == "refused" and "must be an integer" in str(detail): + failures += _fail( + f"{token.decode()} still reaches the VERSION door: {detail!r} " + "— V1 requires the refusal at the JSON door", + check="v1-non-standard-constant") + + # V1 anywhere else in the document is the same refusal: the constants are + # refused by the PARSER, so the position cannot matter. + failures += _refusal_case( + b'{"ownir_version": 0, "components": [{"line": NaN}]}', + check="v1-non-standard-constant", + must_contain="is not valid JSON: NaN is not a JSON value") + + # --- tail 3, V2: the literal top-level `-0` --------------------------- + failures += _refusal_case( + b'{"ownir_version": -0}', check="v2-top-level-negative-zero", + must_contain="must be an integer, got -0:") + + # SCOPE 1 — nested. #261's Version census pinned this as BYTE PARITY with + # the port, so the exact rendering is asserted, not merely the refusal. + outcome, detail = _load_bytes(b'{"ownir_version": [-0]}') + want = "OwnIR 'ownir_version' must be an integer, got [0]" + if outcome != "refused" or str(detail) != want: + failures += _fail( + f"a -0 BELOW the top level must render exactly {want!r} (#261 " + f"Version census, byte parity with the port), got " + f"{outcome}/{detail!r}", check="v2-scope-nested") + + # SCOPE 2 — a `-0` on any other field is not V2 at all and must not even + # be refused: it is an ordinary zero, and the document still analyses. + ok_doc = (b'{"ownir_version": 0, "components": [{"name": "C", ' + b'"file": "a.cs", "line": -0}]}') + outcome, _ = _load_bytes(ok_doc) + if outcome != "ok": + failures += _fail( + "a -0 on an ordinary coordinate field must be an ordinary 0 and " + f"the document must still load, got {outcome}", + check="v2-scope-other-fields") + + # SCOPE 3 — the ruling names the LITERAL `-0`, not "a negative zero". + # These are floats to both implementations, already agree, and must keep + # the float rendering rather than being swept into V2. + for blob in (b'{"ownir_version": -0.0}', b'{"ownir_version": -0e0}'): + outcome, detail = _load_bytes(blob) + want = "OwnIR 'ownir_version' must be an integer, got -0.0" + if outcome != "refused" or str(detail) != want: + failures += _fail( + f"{blob!r} is a FLOAT negative zero, not V2's literal -0; it " + f"must render exactly {want!r}, got {outcome}/{detail!r}", + check="v2-scope-float") + + # --- V3 is NOT reopened ---------------------------------------------- + # + # It rides the same `parse_int` hook the V2 repair installs, so a hook that + # lost arbitrary precision would silently undo #261's repair-2. Both signs, + # because that repair was measured over both. + for sign in ("", "-"): + big = f"{sign}99999999999999999999999999" + outcome, detail = _load_bytes( + b'{"ownir_version": ' + big.encode() + b"}") + want = f"OwnIR facts are schema v{big}, but this core understands v0" + if outcome != "refused" or not str(detail).startswith(want): + failures += _fail( + f"an oversized integral version must keep taking the Version " + f"MISMATCH branch with its full precision ({want!r}), got " + f"{outcome}/{detail!r}", check="v3-not-reopened") + + # --- the ordinary path still works ------------------------------------ + # + # Two hooks now sit in the decode path. The cheapest way for them to be + # wrong is for them to be wrong about everything, so an ordinary document + # with ordinary integers is checked to still load and still carry them as + # plain values. + outcome, doc = _load_bytes( + b'{"ownir_version": 0, "components": [{"name": "C", "file": "a.cs", ' + b'"line": 12, "column": 3}]}') + if outcome != "ok": + failures += _fail(f"an ordinary document must load, got {outcome}: " + f"{doc!r}", check="ordinary-document") + else: + line = doc["components"][0]["line"] # type: ignore[index,call-overload] + if line != 12 or type(line) is not int: + failures += _fail( + f"an ordinary integer must decode as a plain int, got " + f"{line!r} ({type(line).__name__})", check="ordinary-document") + + if failures: + return 1 + print( + "ownir input boundary OK: the three #262 cutover hygiene tails are " + "closed on the PRODUCTION reference path (invalid UTF-8 -> rc 2 with " + "the offending byte and offset; NaN/Infinity/-Infinity refused at the " + "JSON door; the literal top-level -0 refused), each held inside its " + "ruling's scope (nested -0 byte-parity, -0 elsewhere still an ordinary " + "zero, -0.0/-0e0 still floats, a BOM still a JSON refusal), with V3 " + "re-asserted at both signs and the ordinary path unchanged") + return 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/tests/test_stage1_engine.py b/tests/test_stage1_engine.py index 99d8bc53..38a7366d 100644 --- a/tests/test_stage1_engine.py +++ b/tests/test_stage1_engine.py @@ -10,7 +10,9 @@ The fifteen ratified adversarial controls, each named by the misreading it catches: - default-stays-python the default silently becomes Rust + default-is-rust the public default silently goes back to Python, + drifts to compare, or stops reaching the + candidate at all rust-actually-runs-rust explicit Rust selection actually runs Python rust-failure-no-fallback a Rust failure runs Python unexpected-rc-maps-to-5 an unexpected rc escapes as itself instead of 5 @@ -32,6 +34,9 @@ bad-locator-is-2 an invalid OWEN_RUST_CORE produces anything other than exit 2, falls back to Python, or is mapped to 3 or 5 + unset-locator-is-d6 an ABSENT OWEN_RUST_CORE stops resolving this + install's packaged candidate, or starts producing + a Python answer when there is none Failures print `FAIL[]: ` so a mutation campaign names the CHECK that caught it rather than whichever case tripped first, and the run never @@ -67,6 +72,7 @@ import hashlib import json import os +import platform import shutil import subprocess import sys @@ -166,6 +172,32 @@ def have_dotnet() -> bool: return shutil.which("dotnet") is not None +def packaged_core() -> str | None: + """The candidate the launcher under test would resolve with OWEN_RUST_CORE + ABSENT (#262 Stage 3, D6), or None if this install carries none. + + Computed the same way RustCoreLocator does — the directory beside the + launcher assembly, keyed by platform — because "unset" stopped meaning "no + candidate" at Stage 3 and the controls below have to know which of the two + situations they are in. A plain `dotnet build` produces an install with no + packaged core; a `dotnet pack -p:OwenRustCoreDir=...` produces one with it. + Both are legitimate states of this tree, so the answer is MEASURED from the + tree rather than assumed from how it was last built. + """ + dll = launcher_dll() + if dll is None: + return None + arch = "arm64" if platform.machine() in ("arm64", "aarch64") else "x64" + if os.name == "nt": + key, binary = f"win-{arch}", "own-cli.exe" + elif sys.platform == "darwin": + key, binary = f"osx-{arch}", "own-cli" + else: + key, binary = f"linux-{arch}", "own-cli" + cand = Path(dll).parent / "rust-core" / key / binary + return str(cand) if cand.is_file() else None + + def bash_exe() -> str: """The bash that can actually run `own-check.sh`. @@ -297,8 +329,14 @@ def control_bad_locator_is_2(sample: Path, tmp: Path) -> None: a_dir = tmp / "a-directory" a_dir.mkdir(exist_ok=True) + # `unset` is deliberately NOT in this list any more. At Stage 3 an absent + # OWEN_RUST_CORE stopped meaning "no candidate" and started meaning "use the + # one this install ships" (#262 D6) — so on an install that carries a + # packaged core, an unset variable is a SUCCESSFUL resolution and asserting + # exit 2 for it would be asserting that D6 does not work. The unset case has + # its own control below, which asks the question that actually survived the + # cutover: unset must never produce a PYTHON answer. cases = { - "unset": None, "empty": "", "nonexistent": str(tmp / "definitely-absent"), "directory": str(a_dir), @@ -369,6 +407,70 @@ def control_bad_locator_is_2(sample: Path, tmp: Path) -> None: ok(check, f"{total} invalid-locator cases all exit 2, no fallback") +def control_unset_locator_is_d6(sample: Path, tmp: Path) -> None: + """D6: an ABSENT OWEN_RUST_CORE resolves this install's own packaged + candidate — and, when there is none, fails visibly rather than running + Python. + + Both halves are the same claim from two sides, and which one this tree can + answer depends on how it was built, so the control MEASURES that first + instead of assuming it. + + * install carries a packaged core -> an unset variable must produce a + VERDICT, and must not have consulted the development locator to do it; + * install carries none -> exit 2, naming the packaging problem, and denying + a Python fallback in as many words. + + What is common to both, and is the part that matters: an unset variable + never yields a Python answer. Before Stage 3 it could not, because the + default was Python and the locator was never consulted. After Stage 3 the + locator is consulted on every bare run, which is exactly why this case + needed a control of its own rather than a line in the invalid-locator list + it used to live in. + """ + check = "unset-locator-is-d6" + if launcher_dll() is None or not have_dotnet(): + skip(check, "no built launcher/dotnet") + return + env = dict(os.environ) + env.pop("OWEN_RUST_CORE", None) + r = subprocess.run(["dotnet", str(launcher_dll()), "check", str(sample)], + capture_output=True, env=env, cwd=str(ROOT), check=False) + merged = (r.stdout + r.stderr).decode("utf-8", "replace") + shipped = packaged_core() + problems = [] + if shipped is not None: + if b"OWN001" not in r.stdout: + problems.append( + f"this install ships a packaged core at {shipped} but an unset locator " + f"produced no verdict (exit {r.returncode}) — D6 did not resolve it") + if "OWEN_RUST_CORE" in merged: + problems.append("an unset locator complained about OWEN_RUST_CORE — the packaged " + "path must not route through the development locator's diagnostic") + else: + if r.returncode != RustCoreLocatorExitCode: + problems.append( + f"this install ships no packaged core, so an unset locator must be a visible " + f"configuration error (exit {RustCoreLocatorExitCode}), got {r.returncode}") + if b"OWN001" in r.stdout: + problems.append("an unset locator produced a verdict on an install that ships no " + "candidate — something fell back to Python") + if "did not fall back to Python" not in merged: + problems.append("the failure does not deny a Python fallback in as many words") + if problems: + fail(check, "; ".join(problems)) + else: + ok(check, ("an unset locator resolved this install's packaged core" + if shipped is not None + else "an unset locator on an install with no packaged core is a visible " + "configuration error, not a Python answer")) + + +# D3.1's public tier, named once so the control above and the list below cannot +# drift apart on what "a configuration error" means. +RustCoreLocatorExitCode = 2 + + def control_absolute_locator_only(sample: Path, tmp: Path) -> None: """D3: the locator is an ABSOLUTE path, and a relative one is refused. @@ -591,23 +693,67 @@ def control_compare_failure_is_classified(sample: Path, tmp: Path) -> None: ok(check, "a Python-only failure is recorded as execution-failure, not divergence") -def control_default_stays_python(sample: Path) -> None: - """D1: the default engine is Python. Proved NEGATIVELY and positively: a - default run with a deliberately unusable Python must fail on Python (the - launcher's exit 3), which it cannot do if the default silently moved to - Rust; and it must not produce a Rust-only success.""" - check = "default-stays-python" +def control_default_is_rust(sample: Path) -> None: + """#262 Stage 3: the default engine is RUST. + + This control is the inverse of the Stage-1 one it replaces, and it is built + the same way: by breaking the engine that must NOT be reached and by + breaking the engine that must. + + 1. NEGATIVE — a default run with a deliberately unusable OWEN_PYTHON must + SUCCEED. A Python default would exit 3 here (it did, for the whole of + Stages 1 and 2, and that was this control's assertion); so would a + default that had drifted to `compare`, which needs both engines. Passing + proves the default run never resolved Python at all. + + 2. POSITIVE — "did not use Python" is not "used Rust": a default that + silently became a no-op would also pass step 1. So the default run is + repeated with the candidate pointed at a binary forced to fail, and it + must now fail on the RUST path. Only a default that actually routes to + Rust can do both. + + Together those are what make the constant in EngineSelection.Default + load-bearing rather than decorative. + """ + check = "default-is-rust" r = run_owen(["--format", "human", str(sample)], env={"OWEN_PYTHON": "/definitely/not/a/python"}) if r is None: skip(check, "no built launcher/dotnet") return - if r.returncode != 3: - fail(check, f"default run with a broken OWEN_PYTHON exited {r.returncode}, expected 3 " - "(the default engine is not Python any more, or Python is no longer resolved " - "for it)") - return - ok(check, "the default still resolves Python and fails on it (exit 3)") + problems = [] + if r.returncode == 3: + problems.append( + "a default run with a broken OWEN_PYTHON exited 3 — the default engine still " + "resolves Python, so the Stage-3 cutover did not reach this surface") + elif r.returncode not in (0, 1): + problems.append( + f"a default run with a broken OWEN_PYTHON exited {r.returncode}, expected a verdict " + f"(0 clean / 1 findings): the default must not need Python at all. stderr: " + f"{r.stderr.decode('utf-8', 'replace')[:400]}") + + # 2 — and it is Rust specifically, not "not Python". + fault = rust_fault_core() + if fault is None: + skip(check + "/positive", "no OWEN_STAGE1_RUST_FAULT") + else: + rf = run_owen(["--format", "human", str(sample)], + env={"OWEN_RUST_CORE": fault, "OWN_CLI_FAULT_PANIC": "1"}) + if rf is None: + skip(check + "/positive", "no built launcher/dotnet") + elif rf.returncode != 5: + problems.append( + f"a default run whose CANDIDATE was forced to panic exited {rf.returncode}, " + f"expected the public internal-error path 5 — the default did not route to the " + f"Rust candidate, or a Rust failure was turned into something else") + elif b"finding" in rf.stdout: + problems.append("a forced Rust failure still published a verdict on the default path") + + if problems: + fail(check, "; ".join(problems)) + else: + ok(check, "the default runs Rust: it needs no Python, and a forced candidate " + "failure fails it on the Rust path (exit 5)") def control_rust_actually_runs_rust(sample: Path) -> None: @@ -1174,12 +1320,13 @@ def run() -> int: only = [n.strip() for n in os.environ.get("OWEN_STAGE1_ONLY", "").split(",") if n.strip()] controls = { "bad-locator-is-2": lambda: control_bad_locator_is_2(sample_dir, tmp), + "unset-locator-is-d6": lambda: control_unset_locator_is_d6(sample_dir, tmp), "absolute-locator-only": lambda: control_absolute_locator_only(sample_dir, tmp), "locator-shapes": lambda: control_locator_shapes(sample_dir), "compare-failure-classified": lambda: control_compare_failure_is_classified(sample_dir, tmp), "no-selector-in-own-cli": control_no_selector_in_own_cli, - "default-stays-python": lambda: control_default_stays_python(sample_dir), + "default-is-rust": lambda: control_default_is_rust(sample_dir), "rust-actually-runs-rust": lambda: control_rust_actually_runs_rust(sample_dir), "rust-failure-no-fallback": lambda: control_rust_failure_no_fallback(sample_dir), "rc70-is-not-a-verdict": lambda: control_rc70_is_not_a_verdict(sample_dir), diff --git a/tests/test_stage2_dogfood.py b/tests/test_stage2_dogfood.py index 73aa5a85..4c70f66f 100644 --- a/tests/test_stage2_dogfood.py +++ b/tests/test_stage2_dogfood.py @@ -22,7 +22,8 @@ stage2-census every core/launcher call site is classified internal-default-not-rust every Class-D call site selects Rust explicitly - public-default-moved all four public surfaces still resolve Python + public-default-is-rust a public surface is left behind on Python, or + the rollback engine disappears with the cutover rust-job-falls-back a forced Rust failure is never rescued by Python wrong-rust-candidate Class-D runs the production own-cli, and says which locator-contract-bypassed no discovery: OWEN_RUST_CORE or nothing @@ -557,27 +558,27 @@ def control_python_reference_lost() -> None: # --- controls: the public contract ----------------------------------------- -def control_public_default_moved() -> None: - """All four public surfaces still resolve PYTHON when asked for nothing. - - The positive direction, which no amount of grepping for the word "rust" - can give: a bare invocation is RUN, with OWEN_RUST_CORE set to something - that CANNOT work, and it must still produce a verdict. If the public - default had moved to Rust, that run would die on the locator (exit 2) - instead. An unusable candidate is the falsifier here precisely because a - usable one proves nothing — a Rust-default launcher and a Python-default - launcher both succeed when the candidate is fine. - - The `owen` surface additionally gets the other direction: with the - interpreter broken it must fail ON PYTHON (exit 3, no usable runtime), - which is a positive statement about which engine it resolved rather than - an inference from a missing error. own-check.sh cannot be asked that - question the same way — it invokes a bare `python` and has no OWEN_PYTHON - override, which is a real asymmetry between the surfaces and is recorded - here rather than papered over; the unusable-candidate falsifier above does - not depend on it. +def control_public_default_is_rust() -> None: + """All four public surfaces resolve RUST when asked for nothing (#262 Stage 3). + + This control used to assert the opposite, and it is the same control: its + job is that the public default is whatever the stage says it is, and that + nobody can move it quietly. Stage 3 authorized the move, so the assertion + turned over with it. Anything that could still reach the OLD answer is a + surface the cutover did not reach. + + The falsifier is inverted along with the claim. A bare invocation is RUN + with a deliberately unusable PYTHON, and it must still produce a verdict: a + Python default would die on the interpreter, and so would a default that + drifted to `compare`, which needs both engines. A usable interpreter would + prove nothing, because both defaults succeed when everything works. + + And the other direction, because "did not use Python" is not "used Rust": a + bare invocation with an unusable RUST CANDIDATE must FAIL, visibly, and must + not fall back to a Python success. That pair cannot both hold unless the + bare path really is the Rust path. """ - check = "public-default-moved" + check = "public-default-is-rust" problems = [] # The two written-down defaults. @@ -585,13 +586,23 @@ def control_public_default_moved() -> None: m = re.search(r"^ engine:\n(?:.*\n)*? default: \"([a-z]+)\"", action, re.M) if not m: problems.append("action.yml: could not read the engine input's default at all") - elif m.group(1) != "python": - problems.append(f"action.yml: the PUBLIC engine default is {m.group(1)!r}, not python") + elif m.group(1) != "rust": + problems.append(f"action.yml: the PUBLIC engine default is {m.group(1)!r}, not rust — " + "this surface did not move with the others") sel = (ROOT / "frontend/roslyn/OwnSharp.Cli/EngineSelection.cs").read_text(encoding="utf-8") - if not re.search(r"public const Engine Default = Engine\.Python;", sel): - problems.append("EngineSelection.Default is no longer Engine.Python — the product " - "default moved, which is Stage 3 and is not authorized here") + if not re.search(r"public const Engine Default = Engine\.Rust;", sel): + problems.append("EngineSelection.Default is not Engine.Rust — the `owen` surface did " + "not move with the others") + + # The two shell surfaces carry the same default in their own idiom, and a + # cutover that reached three of four is the failure this names. + sh = (ROOT / "scripts/own-check.sh").read_text(encoding="utf-8") + if not re.search(r'^engine="rust"$', sh, re.M): + problems.append("scripts/own-check.sh does not default to rust") + ps1 = (ROOT / "scripts/own-check.ps1").read_text(encoding="utf-8") + if not re.search(r'^\s*\[string\]\$Engine = "rust",$', ps1, re.M): + problems.append("scripts/own-check.ps1 does not default to rust") # The Action forwards its input to own-check.sh, so a default changed in # the forwarding would not show in the input's declared default. @@ -599,65 +610,79 @@ def control_public_default_moved() -> None: problems.append("action.yml no longer forwards its engine input verbatim to " "own-check.sh — the public default could be overridden in transit") + # Python must remain EXPLICITLY selectable on every surface: Stage 3 moved + # the default, and Stage 4 (removing Python) is a different change that has + # not happened. A cutover that quietly took the rollback with it would pass + # every assertion above. + if "python" not in EngineSelection_names(sel): + problems.append("EngineSelection no longer accepts 'python' — the rollback engine " + "disappeared, which is Stage 4 and is not authorized here") + if not have_dotnet(): skip(check, "no dotnet, so the bare surfaces could not be run") return - with tempfile.TemporaryDirectory(prefix="owen-stage2-pub-") as td: + with tempfile.TemporaryDirectory(prefix="owen-stage3-pub-") as td: sample = Path(td) / "sample" sample.mkdir() (sample / "Leak.cs").write_text(SAMPLE_CS, encoding="utf-8") - # A candidate that exists and cannot possibly run. If a bare surface - # selected Rust, this is fatal to it; if it selects Python, it is - # irrelevant to it. - unusable = Path(td) / "not-a-core" - unusable.write_text("this is not an executable image\n", encoding="utf-8") - env = dict(os.environ) - env["OWEN_RUST_CORE"] = str(unusable) - - surfaces: list[tuple[str, list[str]]] = [ - ("own-check.sh", [bash_exe(), str(ROOT / "scripts/own-check.sh"), - "--format", "human", "--", str(sample)]), - ] - dll = launcher_dll() - if dll is not None: - surfaces.append(("owen", ["dotnet", dll, "check", str(sample)])) - for name, argv in surfaces: - r = subprocess.run(argv, capture_output=True, env=env, cwd=str(ROOT), check=False) - merged = (r.stdout + r.stderr).decode("utf-8", "replace") - if b"OWN001" not in r.stdout: - problems.append( - f"{name}: a BARE invocation produced no verdict with an unusable " - f"OWEN_RUST_CORE present (exit {r.returncode}) — it tried to use the Rust " - f"candidate, so the public default has moved [{tail(r)}]") - if "OWEN_RUST_CORE" in merged: - problems.append(f"{name}: a bare invocation complained about OWEN_RUST_CORE — " - "it consulted the Rust locator, which the Python path must not") - - # The other direction, on the one surface that can be asked: with the - # interpreter unusable the default must fail ON PYTHON. - dll = launcher_dll() - if dll is not None: + core = rust_core() + if core is None: + skip(check + "/run", "no OWEN_RUST_CORE, so the bare surfaces could not be run") + else: + # 1 — an unusable INTERPRETER must be irrelevant to a bare run. + env = dict(os.environ) + env["OWEN_RUST_CORE"] = core + env["OWEN_PYTHON"] = str(Path(td) / "no-such-python") + surfaces: list[tuple[str, list[str]]] = [ + ("own-check.sh", [bash_exe(), str(ROOT / "scripts/own-check.sh"), + "--format", "human", "--", str(sample)]), + ] + dll = launcher_dll() + if dll is not None: + surfaces.append(("owen", ["dotnet", dll, "check", str(sample)])) + for name, argv in surfaces: + r = subprocess.run(argv, capture_output=True, env=env, cwd=str(ROOT), check=False) + if b"OWN001" not in r.stdout: + problems.append( + f"{name}: a BARE invocation produced no verdict while only PYTHON was " + f"unusable (exit {r.returncode}) — the default still needs the " + f"reference engine [{tail(r)}]") + + # 2 — an unusable CANDIDATE must be fatal to a bare run, and must + # not be rescued by the interpreter that is sitting right there. + unusable = Path(td) / "not-a-core" + unusable.write_text("this is not an executable image\n", encoding="utf-8") env2 = dict(os.environ) - env2["OWEN_RUST_CORE"] = str(rust_core() or unusable) - env2["OWEN_PYTHON"] = str(Path(td) / "no-such-python") - r = subprocess.run(["dotnet", dll, "check", str(sample)], - capture_output=True, env=env2, cwd=str(ROOT), check=False) - merged = (r.stdout + r.stderr).decode("utf-8", "replace").lower() - if b"OWN001" in r.stdout: - problems.append("owen: a bare invocation produced a verdict while the " - "interpreter was unusable and a GOOD Rust candidate was " - "present — the default resolved Rust") - elif "python" not in merged: - problems.append(f"owen: a bare invocation failed without naming Python " - f"(exit {r.returncode}) [{tail(r)}]") + env2["OWEN_RUST_CORE"] = str(unusable) + for name, argv in surfaces: + r = subprocess.run(argv, capture_output=True, env=env2, cwd=str(ROOT), check=False) + merged = (r.stdout + r.stderr).decode("utf-8", "replace") + if b"OWN001" in r.stdout: + problems.append( + f"{name}: a bare invocation produced a verdict with an UNUSABLE Rust " + f"candidate — something fell back to Python, which no stage allows") + elif r.returncode != 2: + problems.append( + f"{name}: a bare invocation with an unusable candidate exited " + f"{r.returncode}, expected the configuration-error tier 2 [{tail(r)}]") + elif "did not fall back to Python" not in merged: + problems.append( + f"{name}: the failure does not deny a Python fallback in as many words") if problems: fail(check, "; ".join(problems)) else: - ok(check, "action.yml and EngineSelection still default to python, the Action still " - "forwards its input verbatim, and a bare invocation ignores an unusable " - "Rust candidate entirely") + ok(check, "all four surfaces default to rust, the Action still forwards its input " + "verbatim, python remains explicitly selectable, a bare run ignores a broken " + "interpreter, and a broken candidate fails it visibly with no fallback") + + +def EngineSelection_names(source: str) -> list[str]: + """The engine spellings the launcher still accepts, read from its own + Names array rather than assumed.""" + m = re.search(r"Names = \[(.*?)\];", source, re.S) + return re.findall(r'"([a-z]+)"', m.group(1)) if m else [] def control_rust_job_falls_back() -> None: @@ -712,7 +737,7 @@ def control_rust_job_falls_back() -> None: def run() -> int: control_census() control_internal_default_not_rust() - control_public_default_moved() + control_public_default_is_rust() control_rust_job_falls_back() control_wrong_rust_candidate() control_locator_contract_bypassed() diff --git a/tests/test_stage3_cancellation.py b/tests/test_stage3_cancellation.py new file mode 100644 index 00000000..afd3b6f3 --- /dev/null +++ b/tests/test_stage3_cancellation.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""#262 Stage 3 — cancellation/interruption, MEASURED on this platform. + +This is a **reliability** control, not a benchmark. Nothing here times anything +or compares engines for speed, and the workload below is sized only to leave a +window in which an interrupt can arrive while the engine is genuinely running. +The performance gates of #262 are deferred by owner for the Stage-3 decision; +this file makes no performance claim and none may be read out of it. + +## Why measured, and why so little is contracted + +#262's ruling is explicit: *the Windows and Linux reference behaviour is +measured first; 130 is not invented as a universal contract.* The two platforms +do not even offer the same mechanism — POSIX has `SIGINT`, Windows has console +control events — and a process that dies **by a signal** does not have an exit +code at all in the sense a process that `exit()`s does. Writing `130` into a +contract would be inventing a number for one platform and asserting it about +the other. + +So this control separates two things that are easy to conflate: + + INVARIANT — asserted on every platform, because the evidence supports it + DISPOSITION — measured and RECORDED per platform, never asserted + +The invariants are the ones a cutover actually depends on: + +1. **it terminates.** An interrupted engine must not hang. A launcher cannot + rescue a child that never dies. +2. **it is never a verdict.** Not exit 0 and not exit 1. This is the one that + matters: `owen check` maps 0 to "clean" and 1 to "findings", so an interrupt + that produced either would turn a cancelled run into an ANSWER — silently + clean code, or silently a finding — and neither was ever computed. +3. **it publishes no verdict surface.** No `ok` line, no findings summary. A + partially written verdict is still a verdict to whatever reads it. + +The disposition — died by signal N, or exited with code C — is whatever this +platform does, printed and returned so the recording job carries it. That is +the part the decision packet quotes, per platform, from a run that happened. + +## The window, and why a missed one is a failure + +An interrupt delivered after the process already exited measures nothing, and +would "pass" every assertion above for the wrong reason. So the control proves +it interrupted a LIVE process, and escalates the workload when it did not. If +no attempt lands on a live process the control FAILS rather than reporting a +green it did not earn — a zero-window measurement is not evidence. + +Run: python tests/test_stage3_cancellation.py + python tests/run_tests.py (in the suite) + +Environment: + OWEN_RUST_CORE the production candidate; without it the Rust leg + reports NOT APPLICABLE rather than passing + OWEN_STAGE3_REQUIRE=1 turn every skip into a failure (CI sets this: in a + job that exists to provide the toolchain, a skip is + indistinguishable from a pass) +""" + +from __future__ import annotations + +import json +import os +import platform +import signal +import subprocess +import sys +import tempfile +import time + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_HERE) +sys.path.insert(0, _ROOT) + +IS_WINDOWS = os.name == "nt" + +# The verdict exit codes. An interrupted run reaching either of these is the +# defect this whole file exists to catch. +VERDICT_EXITS = (0, 1) + +# How long the child must already have been running when the interrupt is sent. +# Not a performance number: it is the smallest dwell that makes "the process was +# alive" an observation rather than a race. +DWELL_SECONDS = 0.5 + +# How long the child is then given to die. Generous on purpose — the assertion +# is "it terminates", and a slow death is not a hang. +DEATH_TIMEOUT_SECONDS = 30.0 + +# Workload sizes, in components, tried in order until one leaves a window. +# Escalating rather than fixing one size is what keeps this control working on a +# faster machine than the one it was written on. +WORKLOAD_LADDER = (200_000, 600_000, 1_500_000) + + +def _fail(msg: str, *, check: str) -> int: + print(f"FAIL[{check}]: {msg}") + return 1 + + +def _write_workload(path: str, components: int) -> None: + """A large but entirely ORDINARY document. Nothing hostile: the point is a + run long enough to interrupt, not a parser stress case.""" + with open(path, "w", encoding="utf-8") as f: + f.write('{"ownir_version": 0, "components": [') + for i in range(components): + if i: + f.write(",") + f.write(json.dumps({ + "name": f"C{i}", "file": f"f{i}.cs", "line": 1, "kind": "class", + "subscriptions": [{"resource": "e", "event": "E", + "file": f"f{i}.cs", "line": 2, "column": 3}], + })) + f.write("]}") + + +def _spawn(argv: list[str], cwd: str | None) -> subprocess.Popen[bytes]: + """Spawn in its own group so the interrupt reaches the child and NOT this + test process — on either platform. + + The two spellings are not interchangeable and neither is optional: without + a new group the interrupt would land on this test as well, and a control + that kills its own runner reports nothing. + """ + env = dict(os.environ, PYTHONPATH=_ROOT) + if IS_WINDOWS: + return subprocess.Popen( + argv, cwd=cwd, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP) + return subprocess.Popen( + argv, cwd=cwd, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + + +def _interrupt(proc: subprocess.Popen[bytes]) -> str: + """Send this platform's interrupt, and NAME it. + + The two are not the same event and the record must not pretend they are. + On Windows a parent cannot deliver Ctrl-C to a specific child group — the + event a new process group can be sent is CTRL_BREAK — so that is what is + sent, and that is what the disposition below is a measurement of. + """ + if IS_WINDOWS: + proc.send_signal(signal.CTRL_BREAK_EVENT) # type: ignore[attr-defined] + return "CTRL_BREAK_EVENT" + os.killpg(os.getpgid(proc.pid), signal.SIGINT) + return "SIGINT" + + +def _describe(returncode: int) -> tuple[str, str, int]: + """(how, human, raw) for a finished child. A negative returncode is Python's + spelling of "died by signal N" and is NOT an exit code.""" + if returncode < 0: + name = signal.Signals(-returncode).name + return "signal", f"died by signal {-returncode} ({name})", -returncode + return "exit", f"exited with code {returncode}", returncode + + +def _measure(label: str, argv: list[str], cwd: str | None, + workload: str) -> tuple[int, dict[str, object] | None]: + """One engine, interrupted while running. Returns (failures, record).""" + failures = 0 + for components in WORKLOAD_LADDER: + _write_workload(workload, components) + proc = _spawn([*argv, workload], cwd) + time.sleep(DWELL_SECONDS) + if proc.poll() is not None: + # No window: the run finished before the interrupt. Drain it and + # escalate rather than measuring a corpse. + proc.communicate() + continue + mechanism = _interrupt(proc) + try: + out, err = proc.communicate(timeout=DEATH_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + return _fail( + f"{label}: still running {DEATH_TIMEOUT_SECONDS:.0f}s after " + f"{mechanism} — an interrupted engine must terminate", + check="cancellation-terminates"), None + + how, human, raw = _describe(proc.returncode) + stdout = out.decode("utf-8", "replace") + + # INVARIANT 2 — never a verdict. + if how == "exit" and raw in VERDICT_EXITS: + failures += _fail( + f"{label}: {human} after {mechanism} — {raw} is a VERDICT code " + f"({'clean' if raw == 0 else 'findings'}), so a cancelled run " + f"would be published as an answer nobody computed", + check="cancellation-is-not-a-verdict") + # INVARIANT 3 — no verdict surface. + for marker, what in ((": ok — ", "an ok line"), (" finding", "a findings summary")): + if marker in stdout: + failures += _fail( + f"{label}: stdout carries {what} after {mechanism}: " + f"{stdout[:200]!r}", check="cancellation-publishes-nothing") + + record = { + "engine": label, + "mechanism": mechanism, + "how": how, + "raw": raw, + "human": human, + "workload_components": components, + "stdout_bytes": len(out), + "stderr_bytes": len(err), + } + print(f" measured[{label}]: {mechanism} -> {human}; " + f"stdout {len(out)}b, stderr {len(err)}b " + f"(workload {components} components)") + return failures, record + + return _fail( + f"{label}: no workload in {WORKLOAD_LADDER} left a window — every " + f"attempt finished within {DWELL_SECONDS}s, so nothing was interrupted " + f"and nothing was measured", + check="cancellation-window"), None + + +def run() -> int: + require = os.environ.get("OWEN_STAGE3_REQUIRE") == "1" + failures = 0 + records: list[dict[str, object]] = [] + + print(f"cancellation, measured on {platform.system()} " + f"({platform.machine()}), python {platform.python_version()}") + + tmp = tempfile.mkdtemp(prefix="owen-stage3-cancel-") + workload = os.path.join(tmp, "cancel.facts.json") + try: + # The REFERENCE. Always available: it is this repository. + f, record = _measure("python reference", + [sys.executable, "-m", "ownlang", "ownir"], + _ROOT, workload) + failures += f + if record: + records.append(record) + + # The CANDIDATE — the binary the cutover makes default. + core = os.environ.get("OWEN_RUST_CORE") + if not core or not os.path.isfile(core): + msg = ("skip[cancellation-rust]: no OWEN_RUST_CORE — the candidate " + "leg measured nothing") + print(msg) + if require: + failures += _fail( + "OWEN_STAGE3_REQUIRE=1 but OWEN_RUST_CORE is unset or not a " + "file: in a job that exists to provide the candidate, a skip " + "is indistinguishable from a pass", + check="cancellation-rust") + else: + f, record = _measure("rust own-cli", [core, "ownir"], None, workload) + failures += f + if record: + records.append(record) + finally: + try: + os.unlink(workload) + except OSError: + pass + try: + os.rmdir(tmp) + except OSError: + pass + + # The DISPOSITION, emitted as a machine-readable line so the recording job + # carries the measurement rather than a reader transcribing it from prose. + print("STAGE3-CANCELLATION-RECORD " + json.dumps( + {"platform": platform.system(), "engines": records}, sort_keys=True)) + + if failures: + return 1 + print( + f"stage-3 cancellation OK: {len(records)} engine(s) interrupted while " + f"genuinely running on {platform.system()}; each terminated, none " + f"returned a verdict code (0/1), none published an ok line or a " + f"findings summary. The per-platform disposition above is RECORDED, " + f"not contracted — #262 forbids inventing a universal 130") + return 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/tests/test_stage3_packet.py b/tests/test_stage3_packet.py new file mode 100644 index 00000000..87daa2f8 --- /dev/null +++ b/tests/test_stage3_packet.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""#262 Stage 3 — the decision packet cannot quietly say something untrue. + +A decision packet is a form, and a form invites being filled in from memory at +the end of the work. #262 asks for named fields; this module makes the packet a +DERIVED artifact instead, and then holds the ledger it is derived from to the +rules that make the derivation worth anything. + +The rules, and the misreading each one exists to stop: + + deferred-stays-deferred a performance field acquires a number, or softer + wording, and a deferral is read as a pass + no-performance-claim performance language leaks into a field that is + not a performance field + windows-not-inferred a row measured on Linux is presented as covering + Windows, or an OWED Windows row quietly acquires + a Linux-shaped answer + closure-is-not-a-difference a bug closed by this change is listed as a + standing known difference, which would make the + packet describe a defect the tree no longer has + required-differences a difference #262 already ratified goes missing + stage-4-not-now `Python-removal timing` says anything other than + Stage 4 / later + packet-is-generated the committed packet drifts from what the ledger + and the tree currently produce + +Run: python tests/test_stage3_packet.py + python tests/run_tests.py (in the suite) +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(_HERE) +sys.path.insert(0, os.path.join(ROOT, "scripts")) + +LEDGER = os.path.join(ROOT, "docs", "evidence", "p022-stage3-cutover.json") +GENERATED = os.path.join(ROOT, "docs", "generated", "p022-stage3-packet.md") + +# The exact deferral wording the owner ruling requires. Anything else -- a +# number, "n/a", "not applicable", "no regression" -- is a different claim. +DEFERRAL = "DEFERRED BY OWNER — NOT MEASURED" + +# The three fields #262 names and the ruling defers. They must all be present: +# deleting one is how a deferral stops being visible. +DEFERRED_FIELDS = ("startup_delta", "end_to_end_delta", "peak_memory_delta") + +# Differences #262 ratified. A packet that lost one would be a packet that +# stopped disclosing something the owner already decided must be disclosed. +REQUIRED_DIFFERENCES = ("WIN-ABC", "CLI-B1", "V4") + +# Words that turn a measurement into a performance claim. Checked OUTSIDE the +# deferred fields, where the whole point is that no such claim is made. +PERFORMANCE_WORDS = re.compile( + r"\b(faster|slower|speedup|speed-up|throughput|latency|benchmark(?:ed)?|" + r"ms\b|milliseconds?|regressi\w* in (?:time|speed)|performance (?:is|was|budget))", + re.I) + + +def _fail(msg: str, *, check: str) -> int: + print(f"FAIL[{check}]: {msg}") + return 1 + + +def run() -> int: + failures = 0 + if not os.path.isfile(LEDGER): + return _fail(f"{os.path.relpath(LEDGER, ROOT)} is missing — the packet is derived from " + f"it and there is nothing to check", check="ledger-exists") + led = json.load(open(LEDGER, encoding="utf-8")) + + # --- deferred stays deferred ----------------------------------------- + for field in DEFERRED_FIELDS: + got = led.get("deferred_fields", {}).get(field) + if got is None: + failures += _fail( + f"the deferred field {field!r} is missing entirely. #262 asked for it and #263 " + f"still owes it; a packet without the line is a packet nobody can notice is " + f"missing it", check="deferred-stays-deferred") + elif got != DEFERRAL: + failures += _fail( + f"{field} reads {got!r}, not {DEFERRAL!r}. A deferral is not a measurement and " + f"must never be rendered as one — including as 'n/a', 'no change', or a number", + check="deferred-stays-deferred") + + if "deferred by owner" not in led.get("owner_ruling_performance", "").lower(): + failures += _fail("the owner's performance ruling is not recorded verbatim on the " + "decision surface", check="deferred-stays-deferred") + + # --- no performance claim anywhere else ------------------------------- + for m in led.get("measurements", []): + hit = PERFORMANCE_WORDS.search(m.get("result", "")) + if hit: + failures += _fail( + f"measurement {m['id']!r} uses performance language ({hit.group(0)!r}). " + f"Performance is deferred for this decision; a timing encountered incidentally " + f"must not be promoted into a claim", check="no-performance-claim") + + # --- Windows is never inferred from Linux ----------------------------- + seen_platforms = set() + for m in led.get("measurements", []): + seen_platforms.add(m.get("platform")) + owed = m.get("result", "").startswith("OWED") + if owed and m.get("label") != "DEFERRED EVIDENCE": + failures += _fail( + f"{m['id']!r} is OWED but is not labelled DEFERRED EVIDENCE — an unlabelled " + f"gap reads as a result", check="windows-not-inferred") + if m.get("label") == "DEFERRED EVIDENCE" and not owed: + failures += _fail( + f"{m['id']!r} is labelled DEFERRED EVIDENCE but carries a result. Either it was " + f"measured, in which case say where, or it was not", + check="windows-not-inferred") + if "windows" not in seen_platforms: + failures += _fail( + "no measurement names the windows platform at all, so the packet cannot be said to " + "cover it either way", check="windows-not-inferred") + + # --- a closure is not a standing difference --------------------------- + closed = {c["id"] for c in led.get("closed_in_this_change", [])} + diffs = {d["id"] for d in led.get("known_differences", [])} + both = closed & diffs + if both: + failures += _fail( + f"{sorted(both)} appear as BOTH closed and as standing known differences. #262 says " + f"not to list a closed bug as a known difference merely because it used to exist", + check="closure-is-not-a-difference") + for wanted in REQUIRED_DIFFERENCES: + if wanted not in diffs: + failures += _fail( + f"the ratified difference {wanted!r} is not carried forward into the packet", + check="required-differences") + if not closed: + failures += _fail("nothing is recorded as CLOSED, although this change closes three " + "#262 hygiene tails — closures must be recorded separately from " + "differences", check="closure-is-not-a-difference") + + # --- Stage 4 is not now ---------------------------------------------- + from stage3_packet import packet # scripts/ is on sys.path (set above) + text = packet() + m = re.search(r"^Python-removal timing:\s*(.+)$", text, re.M) + if not m: + failures += _fail("the packet has no `Python-removal timing:` line", + check="stage-4-not-now") + elif ("Stage 4" not in m.group(1) + or re.search(r"\bnow\b(?!\.)", m.group(1).replace("NOT now", ""))): + failures += _fail( + f"`Python-removal timing:` reads {m.group(1)!r}; it must say Stage 4 / a separate PR " + f"/ after the observation policy, and must not say now", check="stage-4-not-now") + + # --- the packet is generated, not typed ------------------------------- + if not os.path.isfile(GENERATED): + failures += _fail(f"{os.path.relpath(GENERATED, ROOT)} has not been generated " + f"(`python scripts/stage3_packet.py --write`)", + check="packet-is-generated") + else: + committed = open(GENERATED, encoding="utf-8").read() + # The candidate SHA and the dirty marker move with the tree, so they are + # normalised out: what must not drift is everything else. + norm = re.compile(r"^Stage-3 candidate SHA:.*$", re.M) + if norm.sub("", committed).strip() != norm.sub("", text).strip(): + failures += _fail( + "the committed packet differs from what the ledger and the tree produce now — " + "regenerate it with `python scripts/stage3_packet.py --write` rather than " + "editing it", check="packet-is-generated") + + # --- the candidate SHA is this tree ----------------------------------- + head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, capture_output=True, + text=True, check=False).stdout.strip() + if head and led.get("candidate_sha") and led["candidate_sha"] != head: + ancestor = subprocess.run( + ["git", "merge-base", "--is-ancestor", led["candidate_sha"], "HEAD"], + cwd=ROOT, capture_output=True, check=False).returncode == 0 + if not ancestor: + failures += _fail( + f"the ledger's candidate_sha {led['candidate_sha'][:12]} is not this tree and " + f"not an ancestor of it — the packet describes a tree nobody has", + check="candidate-sha-is-this-tree") + + if failures: + return 1 + print( + f"stage-3 packet OK: {len(led['measurements'])} measurements, " + f"{len(led['known_differences'])} known differences, {len(closed)} recorded closures; " + f"all three performance fields present and reading exactly {DEFERRAL!r}; no performance " + f"language outside them; every OWED row labelled DEFERRED EVIDENCE and none of them " + f"filled in from another platform; Python removal says Stage 4; the committed packet " + f"matches what the ledger produces") + return 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/tests/test_stage3_rollback.py b/tests/test_stage3_rollback.py new file mode 100644 index 00000000..6100fd7c --- /dev/null +++ b/tests/test_stage3_rollback.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""#262 Stage 3 — the rollback path, and the four states it is easy to conflate. + +Stage 3 moved the public default to Rust. #262 requires that the way back is +**documented, explicit and tested**, and that it is never automatic: + + Rollback must be a documented engine selection or package patch, not a + hidden automatic fallback. A Rust failure must remain observable. + +The whole content of this file is that "explicit" and "automatic" are +different, and that proving the first does not prove the absence of the second. +A launcher with a hidden fallback passes any test that only ever asks for +Python on purpose -- it would answer correctly every time. So the states are +driven apart: + + 1 default, candidate fine -> RUST runs + 2 Python explicitly selected -> PYTHON runs + 3 candidate BROKEN, nothing asked -> VISIBLE FAILURE + 4 candidate BROKEN, Python asked -> PYTHON runs + +3 and 4 are the pair that matters and they are one flag apart. A hidden +fallback makes 3 look like 4: the user gets an answer, from an engine they did +not choose, and nothing says so. 1 and 2 together are what makes 3 and 4 mean +anything -- without them a launcher that simply never worked would pass 3. + +The rollback is also proved to be a rollback rather than merely A path: in +state 4 the finding Python produces is compared against the finding Rust +produced in state 1, over the same sample, because a rollback that answered +something else would not be a rollback. + +Run: python tests/test_stage3_rollback.py + python tests/run_tests.py (in the suite) + +Environment: + OWEN_RUST_CORE the production candidate (required to measure) + OWEN_STAGE1_LAUNCHER_DLL the built `ownsharp.dll` (the `owen` launcher) + OWEN_STAGE3_REQUIRE=1 turn every skip into a failure +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_HERE) +sys.path.insert(0, _ROOT) + +SAMPLE_CS = """using System.IO; +public class Leaky +{ + public void Run() + { + var s = new MemoryStream(); + s.WriteByte(1); + } +} +""" + +# The one finding every state below is measured against. +EXPECT_CODE = b"OWN001" + + +def verdict(proc: subprocess.CompletedProcess[bytes]) -> tuple[int, tuple[str, ...]]: + """What a run ANSWERED, as opposed to what bytes it wrote. + + The difference is not pedantry here, it is #262's Windows ruling. On a piped + Windows stdout the Python reference encodes cp1252 and translates line + endings to CRLF; the Rust core emits canonical UTF-8 on both platforms. So + the two engines' BYTES legitimately differ on Windows, and #262 records that + as a deliberate behaviour change with native-Windows Python parity + explicitly NOT claimed. + + An earlier version of this control compared raw stdout between the default + and the rollback. It passed on Linux, where the reference IS canonical, and + failed on Windows -- correctly, because it was asserting the one claim #262 + says is not made. Comparing the verdict instead asks the question the + rollback actually has to answer: did the way back reach the same finding at + the same exit code? The BYTE-level relationship between the engines is + #260's compare matrix's business, not this control's. + """ + text = proc.stdout.decode("utf-8", "replace").replace("\r\n", "\n") + codes = tuple(sorted({ + m for line in text.splitlines() + for m in re.findall(r"\[(OWN\d{3}|OBL\d{3})\]", line) + })) + return proc.returncode, codes + +# D3.1: a candidate that cannot be resolved or started is a CONFIGURATION +# error, in the same tier as a usage mistake. Never 3 (Python-specific), never +# 5 (Owen's own internal failure), and never a verdict. +CONFIG_ERROR = 2 + + +def _fail(msg: str, *, check: str) -> int: + print(f"FAIL[{check}]: {msg}") + return 1 + + +def _skip(check: str, why: str) -> None: + print(f"skip[{check}]: {why}") + + +def _bash() -> str: + """The bash that can actually run `own-check.sh`. + + On a Windows runner `bash` on PATH is C:\\Windows\\System32\\bash.exe -- the + WSL launcher, not a shell. With no distribution installed it exits 1 having + run nothing, and 1 is the launcher's code for FINDINGS. So this control read + "WSL is not installed" as "the default engine ran and found nothing", and + reported that state 1 had failed. It is a harness concern, not a product + one: a Windows user runs own-check.sh from a git-bash prompt, where `bash` + is already the right one. The same helper exists in test_stage1_engine.py + for the same reason. + """ + if os.name != "nt": + return "bash" + candidates = [ + os.environ.get("SHELL"), + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe", + shutil.which("bash"), + ] + for cand in candidates: + if cand and "system32" not in cand.lower() and os.path.isfile(cand): + return cand + return "bash" + + +def _launcher() -> str | None: + dll = os.environ.get("OWEN_STAGE1_LAUNCHER_DLL") + return dll if dll and os.path.isfile(dll) else None + + +def _core() -> str | None: + core = os.environ.get("OWEN_RUST_CORE") + return core if core and os.path.isfile(core) else None + + +def _surfaces(sample: Path) -> list[tuple[str, list[str], list[str]]]: + """(name, argv for a DEFAULT run, argv for an EXPLICIT PYTHON run). + + Both shell surfaces and the launcher are driven, because "the rollback + works" is a claim about the launcher surfaces a user actually has, and + Stage 3 moved all of them together. + """ + out: list[tuple[str, list[str], list[str]]] = [ + ("own-check.sh", + [_bash(), str(Path(_ROOT) / "scripts/own-check.sh"), "--format", "human", + "--", str(sample)], + [_bash(), str(Path(_ROOT) / "scripts/own-check.sh"), "--format", "human", + "--engine", "python", "--", str(sample)]), + ] + dll = _launcher() + if dll is not None: + out.append(( + "owen", + ["dotnet", dll, "check", "--format", "human", str(sample)], + ["dotnet", dll, "check", "--format", "human", "--engine", "python", str(sample)])) + return out + + +def _run(argv: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[bytes]: + return subprocess.run(argv, capture_output=True, cwd=_ROOT, + env=dict(os.environ, **env), check=False) + + +def run() -> int: + require = os.environ.get("OWEN_STAGE3_REQUIRE") == "1" + core = _core() + if core is None: + _skip("stage3-rollback", "no OWEN_RUST_CORE, so no state could be measured") + if require: + return _fail("OWEN_STAGE3_REQUIRE=1 but OWEN_RUST_CORE is unset or not a file", + check="stage3-rollback") + return 0 + if shutil.which("dotnet") is None: + _skip("stage3-rollback", "no dotnet, so no surface could be run") + if require: + return _fail("OWEN_STAGE3_REQUIRE=1 but dotnet is not available", + check="stage3-rollback") + return 0 + + failures = 0 + with tempfile.TemporaryDirectory(prefix="owen-stage3-rollback-") as td: + sample = Path(td) / "sample" + sample.mkdir() + (sample / "Leak.cs").write_text(SAMPLE_CS, encoding="utf-8") + + # A candidate that EXISTS and cannot possibly run. Deliberately not a + # missing path: "broken" and "absent" reach the same tier by design, and + # the harder of the two to get right is the one that is there. + broken = Path(td) / "not-a-core" + broken.write_text("this is not an executable image\n", encoding="utf-8") + broken.chmod(0o755) + + good = {"OWEN_RUST_CORE": core} + bad = {"OWEN_RUST_CORE": str(broken)} + + for name, default_argv, python_argv in _surfaces(sample): + # STATE 1 — the default, everything fine. Rust runs. + r1 = _run(default_argv, good) + if EXPECT_CODE not in r1.stdout: + failures += _fail( + f"{name}: the DEFAULT run produced no {EXPECT_CODE.decode()} " + f"(exit {r1.returncode}) — state 1 is the baseline the others are " + f"measured against", + check="rollback-state-1-default-is-rust") + continue + + # STATE 2 — Python explicitly selected. It runs, and it AGREES. + r2 = _run(python_argv, good) + if EXPECT_CODE not in r2.stdout: + failures += _fail( + f"{name}: the explicit rollback produced no {EXPECT_CODE.decode()} " + f"(exit {r2.returncode}) — the documented way back does not work", + check="rollback-state-2-explicit-python") + elif verdict(r2) != verdict(r1): + # Not a parity gate (that is #260's job over the whole matrix) — + # a rollback-specific one: the way back must lead to the same + # answer, or it is a different product rather than a rollback. + failures += _fail( + f"{name}: the rollback's VERDICT differs from the default's on the same " + f"sample.\n default : {verdict(r1)}\n rollback: {verdict(r2)}", + check="rollback-state-2-agrees") + + # STATE 3 — candidate broken, NOTHING asked for. Visible failure. + r3 = _run(default_argv, bad) + merged3 = (r3.stdout + r3.stderr).decode("utf-8", "replace") + if EXPECT_CODE in r3.stdout: + failures += _fail( + f"{name}: a broken candidate with NO rollback requested still produced a " + f"verdict — something fell back to Python automatically, which no stage " + f"of #262 allows", + check="rollback-state-3-no-automatic-fallback") + elif r3.returncode != CONFIG_ERROR: + failures += _fail( + f"{name}: a broken candidate exited {r3.returncode}, expected the " + f"configuration-error tier {CONFIG_ERROR} (not 3 — that is " + f"Python-specific; not 5 — that is an internal failure)", + check="rollback-state-3-no-automatic-fallback") + elif "did not fall back to Python" not in merged3: + failures += _fail( + f"{name}: the failure does not DENY a Python fallback in as many words, so " + f"a reader cannot tell state 3 from a silent one", + check="rollback-state-3-no-automatic-fallback") + + # STATE 4 — candidate broken AND Python asked for. Python runs. + # One flag away from state 3, and the whole point of keeping them + # apart: this must work even though the candidate is unusable, + # because the rollback does not depend on the thing being rolled + # back from. + r4 = _run(python_argv, bad) + if EXPECT_CODE not in r4.stdout: + failures += _fail( + f"{name}: the rollback did not run with a BROKEN candidate present " + f"(exit {r4.returncode}) — the way back must not depend on the engine it " + f"is a way back from. stderr: " + f"{r4.stderr.decode('utf-8', 'replace')[:300]}", + check="rollback-state-4-works-when-rust-is-broken") + elif verdict(r4) != verdict(r1): + failures += _fail( + f"{name}: the rollback under a broken candidate answered differently from " + f"the default's baseline.\n default : {verdict(r1)}\n " + f"rollback: {verdict(r4)}", + check="rollback-state-4-works-when-rust-is-broken") + + if not failures: + print(f" ok[{name}]: default=Rust; explicit python agrees; broken candidate " + f"fails visibly (exit {r3.returncode}); explicit python still runs") + + if failures: + return 1 + print( + "stage-3 rollback OK: on every launcher surface the four states stay distinct — the " + "default runs Rust, an explicit --engine python runs the reference and agrees with it, " + "a broken candidate with nothing asked for is a visible configuration error that denies " + "a fallback in as many words, and an explicit --engine python still runs when the " + "candidate is broken. No state produced an answer from an engine nobody selected. " + "Agreement is measured as the VERDICT, not the bytes: #262 declares the Windows " + "reference's cp1252/CRLF output a behaviour change and does not claim byte parity " + "with it") + return 0 + + +if __name__ == "__main__": + raise SystemExit(run()) diff --git a/tests/test_stage3_surfaces.py b/tests/test_stage3_surfaces.py new file mode 100644 index 00000000..b92dd6e1 --- /dev/null +++ b/tests/test_stage3_surfaces.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""#262 Stage 3 — every CI launcher invocation can actually reach an engine. + +The cutover's quietest failure mode is not a red job. It is a GREEN one. + +A step that injects a broken Python and then invokes a launcher surface BARE +tested something real for the whole of Stages 1 and 2, because the bare surface +resolved Python. After Stage 3 the bare surface resolves Rust, never consults +`OWEN_PYTHON` at all, and that same step passes while proving nothing. Five such +steps existed in this repository when the default moved. Two more jobs would +have gone RED instead, because their bare invocation had no candidate to +resolve and would have exited 2 before doing any work. + +Both directions are the same question — *which engine does this step actually +reach, and can it?* — so both are asked here, over the workflows themselves +rather than over a list someone maintains by hand. + +## The rule + +Every step that invokes a launcher surface is either: + +* **EXPLICIT** — it names `--engine` / `-Engine`, so the cutover cannot have + changed what it measures; or +* **BARE** — it runs the public default, and its job must then be able to + RESOLVE that default. A bare invocation in a job with no candidate is not a + test of the public default, it is an exit 2. + +A job supplies a candidate in one of three ways, and this is the whole list: + + OWEN_RUST_CORE= the ratified locator (D3), set by the job + OwenRustCoreDir the job packs the candidate INTO the package it then + installs (D6) + uses: ./ the Action, which builds its own production own-cli + +plus one cross-job case: a job that installs `Owen.Cli` from an artifact built +by another job in the same workflow is served by that job's pack. + +## What this is not + +It is not a claim that every bare step SHOULD be bare, and it does not know +which engine a step ought to measure — that is the Stage-2 census's job, by +role. This only refuses the two states in which a step cannot mean what it +says: a bare invocation that cannot reach an engine, and a step that names an +engine-specific environment variable while invoking bare. + +Run: python tests/test_stage3_surfaces.py + python tests/run_tests.py (in the suite) +""" + +from __future__ import annotations + +import os +import re + +_HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(_HERE) +WORKFLOWS = os.path.join(ROOT, ".github", "workflows") + +# An invocation of one of the four launcher surfaces. `uses: ./` is the Action, +# which is a launcher surface too and carries its engine as an input. +INVOCATION = re.compile( + r"(?:\bbash\s+)?(?:\./)?(?:scripts[/\\])own-check\.(?:sh|ps1)\b" + r"|(? list[str]: + """The file with comment lines and triple-quoted blocks blanked out, line + numbering intact so a finding still points at the right line.""" + out: list[str] = [] + fence: str | None = None + for line in text.splitlines(): + stripped = line.strip() + if fence is not None: + out.append("") + if fence in line: + fence = None + continue + opened = next((q for q in _TRIPLE if q in line), None) + if opened is not None: + out.append("") + if line.count(opened) == 1: + fence = opened + continue + out.append("" if stripped.startswith("#") else line) + return out + +# The spellings that make a step explicit about its engine. +EXPLICIT = re.compile(r"--engine\b|-Engine\b") + +# Engine-specific environment variables. Setting one of these and then invoking +# BARE is not wrong by itself -- it is wrong when the step EXPECTS the injection +# to take effect, because since the cutover a bare invocation never consults +# them. So the step's own assertion is what decides: +PYTHON_SPECIFIC = re.compile(r"\bOWE?N_PYTHON=") + +# ...it expects the injection to BITE (so it must name the engine): it asserts +# the Python-resolution tier, or reads a Python-specific message back out. +EXPECTS_PYTHON_TO_BITE = re.compile( + r"-eq\s+3\b|expected exit 3|grep -q[i]?\s+\"?OWE?N_PYTHON" + r"|ownlang: internal error|OWN_PYTHON is deprecated") + +# A second, quieter shape of the same mistake, and the one that went RED in CI +# rather than green: a step that asserts something about the VENDORED PYTHON +# CORE'S CACHE and then invokes bare. Only the Python engine unpacks +# ~/.owen/core, so after the cutover those assertions are about a directory +# nothing wrote. This needs no injected variable to go wrong, which is why the +# environment alone was not enough to detect it. +ASSERTS_PYTHON_CACHE = re.compile( + r"\[ -d \"\$HOME/\.owen\"|\$HOME/\.owen\"? \]|" + r"expected a fresh ~/\.owen unpack|~/\.owen/core|\$HOME/\.owen/core") + +# ...or it expects the injection to be IGNORED, which is exactly what a Stage-3 +# cutover assertion looks like: break Python, run bare, demand a verdict anyway. +EXPECTS_PYTHON_IGNORED = re.compile(r"-eq\s+[01]\b|expected 1 \(findings\)") + +# How a job can supply the public default's candidate. +SUPPLIES_CANDIDATE = re.compile( + r"OWEN_RUST_CORE\s*=|OWEN_RUST_CORE:|OwenRustCoreDir|uses:\s*\./") + +# A script has no job to supply a candidate, so a bare invocation in one is a +# CHOICE: it follows the public default and leaves the locator to its caller. +# That choice has to be stated at the call site, in these words. +DECLARES_DEFAULT = re.compile(r"no --engine here on purpose") + +# A job that installs the tool rather than building it is served by whichever +# job in the same workflow packed it. +INSTALLS_PACKAGE = re.compile(r"dotnet tool install.*Owen\.Cli", re.I) + + +def _fail(msg: str, *, check: str) -> int: + print(f"FAIL[{check}]: {msg}") + return 1 + + +def _jobs(text: str) -> list[tuple[int, str]]: + """(line index, job id) for every job in a workflow, in order.""" + return [(i, m.group(1)) + for i, line in enumerate(text.splitlines()) + if (m := re.match(r"^ ([a-z0-9_-]+):\s*$", line))] + + +def _owner(jobs: list[tuple[int, str]], line: int) -> str: + owned = [j for j in jobs if j[0] <= line] + return owned[-1][1] if owned else "" + + +def _step_text(lines: list[str], line: int) -> str: + """The body of the step containing `line`: from its own `- name:` to the + next one at the same indent. The step's ASSERTION is what says whether an + injection was meant to take effect, so the question cannot be answered from + the invocation line alone.""" + start = 0 + indent = "" + for i in range(line, -1, -1): + m = re.match(r"^(\s*)- name:", lines[i]) + if m: + start, indent = i, m.group(1) + break + end = len(lines) + for i in range(start + 1, len(lines)): + if re.match(rf"^{indent}- (name|uses):", lines[i]): + end = i + break + return "\n".join(lines[start:end]) + + +def _job_text(text: str, jobs: list[tuple[int, str]], job: str) -> str: + lines = text.splitlines() + starts = [i for i, name in jobs if name == job] + if not starts: + return "" + start = starts[0] + after = [i for i, _ in jobs if i > start] + return "\n".join(lines[start:after[0] if after else len(lines)]) + + +def _action_build_is_reproducible() -> int: + """The Action builds `own-cli` on a consumer's runner, so that build has to + mean one thing over time. + + OWNER RULING (#262 Stage 3): ACTION-BUILD is accepted as a declared + TEMPORARY distribution cost, not a parity difference and not a blocker. The + exit condition is the first published own-cli/Owen.Cli artifact, at which + point this Action downloads an immutable platform binary instead. Until + then, the inputs to that build are pinned -- all of them. + + Three were, and one was not: + + source pinned by the action ref the caller writes + dependencies pinned by rust/Cargo.lock ... only with --locked + actions pinned by SHA + rustc FLOATING on `stable` + + A caller who pins `PhysShell/Own.NET@` is entitled to have that tag + mean one thing. With a moving channel the same tag builds with whatever + rustc shipped that month, so a future release that compiled the crate + differently -- or refused it -- would change the behaviour of a revision + nobody touched. For a migration cutover that is a variable with no upside, + which is why it is asserted here rather than left to a comment. + """ + failures = 0 + text = open(os.path.join(ROOT, "action.yml"), encoding="utf-8").read() + + m = re.search(r"toolchain:\s*[\"']?([^\"'\s]+)", text) + if not m: + failures += _fail("action.yml names no Rust toolchain at all", + check="action-build-is-reproducible") + elif m.group(1) in ("stable", "beta", "nightly"): + failures += _fail( + f"action.yml pins the Rust toolchain to the moving channel {m.group(1)!r}. A " + f"consumer-facing surface needs a concrete version, or the same action tag builds " + f"with a different compiler over time", check="action-build-is-reproducible") + elif not re.fullmatch(r"\d+\.\d+(\.\d+)?", m.group(1)): + failures += _fail( + f"action.yml's toolchain {m.group(1)!r} is not a concrete version", + check="action-build-is-reproducible") + + # Comment lines are excluded, for the third time in this file: the prose + # justifying these pins contains the phrase "cargo build" and was duly + # reported as an unlocked build. Only code counts. + code = "\n".join(ln for ln in text.splitlines() if not ln.lstrip().startswith("#")) + for build in re.findall(r"cargo build[^\n]*", code): + if "--locked" not in build: + failures += _fail( + f"action.yml builds without --locked ({build.strip()!r}). rust/Cargo.lock is " + f"committed; without --locked a yanked or newly published dependency can change " + f"what an unchanged source revision builds, and the build succeeds while quietly " + f"not being the qualified one", check="action-build-is-reproducible") + return failures + + +def run() -> int: + failures = _action_build_is_reproducible() + bare = explicit = ignored_ok = 0 + checked_files = 0 + + # The workflows, and then the SCRIPTS the workflows call. Two of the three + # call sites the cutover broke were not in any YAML at all: `benchmark.py` + # shells out to own-check.sh from Python and `mine.sh` from bash, so a + # control that only read the workflows declared victory over them. + sources = [(n, os.path.join(WORKFLOWS, n)) + for n in sorted(os.listdir(WORKFLOWS)) + if n.endswith((".yml", ".yaml"))] + scripts_dir = os.path.join(ROOT, "scripts") + sources += [(f"scripts/{n}", os.path.join(scripts_dir, n)) + for n in sorted(os.listdir(scripts_dir)) + if n.endswith((".py", ".sh")) + # The launcher surfaces themselves are not call sites of + # themselves, and perf_baseline is #263's instrument, which + # drives BOTH engines by parameter and names each one. + and n not in ("own-check.sh", "own-check.ps1")] + + for name, path in sources: + if False: + continue + text = open(path, encoding="utf-8").read() + checked_files += 1 + jobs = _jobs(text) + workflow_packs = bool(re.search(r"OwenRustCoreDir", text)) + # A script has no jobs: a bare invocation in one is served by whichever + # caller sets the locator, so what this control can assert about it is + # that the choice was MADE rather than inherited by accident. A script + # that runs the default says so in a comment naming Stage 3; anything + # else has to name its engine. + is_script = name.startswith("scripts/") + + scan_lines = _code_only(text) if is_script else text.splitlines() + for i, line in enumerate(scan_lines): + stripped = line.strip() + # Comments and the `on:` path filters are not invocations. + if stripped.startswith("#") or stripped.startswith("- \""): + continue + matcher = SCRIPT_INVOCATION if is_script else INVOCATION + if not matcher.search(line): + continue + if is_script: + stmt = "\n".join( + ln for ln in scan_lines[max(0, i - 2):i + 30] if ln.strip()) + launcher = LAUNCHES_PY if name.endswith(".py") else LAUNCHES_SH + if not launcher.search(stmt if name.endswith(".py") else line): + continue + # A line that merely NAMES the script (a step title, an echo, a + # path variable) is not an invocation of it. + if stripped.startswith("- name:") or stripped.startswith("name:"): + continue + if re.match(r'^(echo|Write-Host|\$script\s*=|if \(\$LASTEXITCODE)', stripped): + continue + + job = _owner(jobs, i) + where = f"{name}:{i + 1}" + (f" [{job}]" if not name.startswith("scripts/") else "") + # In a script the path and the flags are routinely on different + # lines -- `sh = str(ROOT / "scripts/own-check.sh")` and the argv + # built three lines later -- so the unit is the STATEMENT, not the + # line. A window rather than a parser, because the question is only + # "was an engine named here", and a wrong answer in either direction + # is caught by the assertion, not hidden by it. + context = line + if is_script: + # Counted in CODE lines, not raw ones. _code_only blanks + # comments, and the comments explaining these call sites run to + # eight lines apiece -- a raw-line window measured the prose and + # stopped short of the argv it was looking for. + window = [ln for ln in scan_lines[max(0, i - 2):] if ln.strip()][:10] + # COMMENTS ARE STRIPPED, and that is not tidiness. The comments + # explaining these very call sites say things like "--engine + # python is EXPLICIT", so a matcher that read them would find + # the flag in the prose after a mutation had removed it from the + # argv -- which is exactly what happened, twice, while this + # control was being written. Only code counts as a flag. + context = "\n".join( + ln for ln in window if not ln.lstrip().startswith("#")) + if EXPLICIT.search(context): + explicit += 1 + continue + bare += 1 + + if is_script: + # The declaration has to sit AT the call site, not somewhere in + # the file. A first version accepted any Stage-3 comment + # mentioning the word "default" anywhere in the module, and a + # mutation that took benchmark.py's engine flag back off + # survived it -- exempted by the very comment explaining why the + # flag was there. + declares_here = bool( + DECLARES_DEFAULT.search("\n".join( + text.splitlines()[max(0, i - 10):i + 2]))) + if not declares_here: + failures += _fail( + f"{where}: a BARE launcher invocation inside a script. A script has no " + f"job to supply a candidate, so after the cutover this either needs to " + f"name the engine it is about, or to say in a comment that it " + f"deliberately follows the public default (#262 Stage 3) and leave the " + f"locator to its caller.\n {stripped[:150]}", + check="bare-invocation-can-reach-an-engine") + continue + + body = _job_text(text, jobs, job) + served = bool(SUPPLIES_CANDIDATE.search(body)) + if not served and INSTALLS_PACKAGE.search(body) and workflow_packs: + served = True + if not served: + failures += _fail( + f"{where}: a BARE launcher invocation in a job that supplies no candidate. " + f"The public default is Rust (#262 Stage 3), so this step cannot reach an " + f"engine: it will exit 2 before doing any work. Either name the engine it " + f"is actually about (--engine/-Engine) or give the job a candidate " + f"(OWEN_RUST_CORE, OwenRustCoreDir, or the Action).\n {stripped[:150]}", + check="bare-invocation-can-reach-an-engine") + + step = _step_text(text.splitlines(), i) + if ASSERTS_PYTHON_CACHE.search(step): + failures += _fail( + f"{where}: this step ASSERTS something about the vendored Python core's " + f"cache (~/.owen/core) and then invokes the launcher BARE. Only the Python " + f"engine unpacks that cache, so since the cutover the assertion is about a " + f"directory nothing wrote. Name the engine it is about.\n " + f"{stripped[:150]}", + check="python-cache-assertion-needs-an-explicit-engine") + + if PYTHON_SPECIFIC.search(line): + if EXPECTS_PYTHON_TO_BITE.search(step): + failures += _fail( + f"{where}: this step injects a broken Python and then invokes the " + f"launcher BARE, while ASSERTING that the injection took effect. Since " + f"the cutover a bare invocation never consults OWEN_PYTHON, so the " + f"injection cannot happen -- this step can only pass by proving nothing. " + f"Name the engine it is about.\n {stripped[:150]}", + check="python-injection-needs-an-explicit-engine") + elif not EXPECTS_PYTHON_IGNORED.search(step): + failures += _fail( + f"{where}: this step injects a broken Python into a BARE invocation and " + f"asserts neither that the injection bit nor that it was ignored, so " + f"nobody can tell which it meant.\n {stripped[:150]}", + check="python-injection-needs-an-explicit-engine") + else: + ignored_ok += 1 + + if not bare and not explicit: + return _fail( + "no launcher invocation was found in any workflow — the matcher stopped matching, " + "so this control was asserting over an empty set", + check="stage3-surfaces-non-vacuous") + + if failures: + return 1 + print( + f"stage-3 CI surfaces OK: {explicit + bare} launcher invocations over {checked_files} " + f"workflows and scripts — {explicit} name their engine explicitly, {bare} run " + f"the public default and " + f"every one of them is in a job that can resolve it. {ignored_ok} step(s) inject a broken " + f"Python into a bare invocation and assert it is IGNORED, which is the cutover " + f"assertion; none assert an injection that can no longer happen. The Action's own build " + f"is reproducible: a concrete rustc, --locked, SHA-pinned actions") + return 0 + + +if __name__ == "__main__": + raise SystemExit(run())