diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5066aa2f..81da4577 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,6 +196,19 @@ jobs: export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext" export OWEN_STAGE1_STUB="$RUNNER_TEMP/stage1-stub$ext" python tests/test_stage1_ps1.py + # P-022 Stage 2 (#262): the CI/dogfood census and the Rust-default + # controls. They live here because this is the job that already has the + # candidate, the launcher and both platforms — and REQUIRE=1 because a + # census that skips is a census that measured nothing. + - name: Stage-2 dogfood controls (census + Rust-default + public contract) + env: + OWEN_STAGE2_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_stage2_dogfood.py # The explicit Rust-selected run on this platform, through the shell # launcher, recorded as its own step so the evidence names the surface # and the platform rather than being inferred from a green job. @@ -321,6 +334,85 @@ jobs: sys.exit(1 if problems else 0) PY + # P-022 step 8 (#262) STAGE 2 — the same campaign, on Windows, as a GATE. + # + # The Linux run is the recorded one and every Stage-2 mutant edits declarative + # text, so the verdicts ought to be identical here. "Ought to" is the word + # that cost this branch two review rounds: the Stage-2 controls themselves + # failed on Windows and passed on Linux, because a path key built with the + # host separator missed every ledger entry. That defect was in the harness, + # not in a mutant, and no Linux campaign could have reported it. + # + # So the campaign is MEASURED on both platforms rather than argued to be + # platform-independent. This job records nothing — the committed provenance + # stays the Linux run — it only fails if Windows disagrees. + stage2-windows-mutations: + name: Stage-2 mutation campaign, Windows verdict (gate only, records nothing) + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: "8.0.x" + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + - name: Build the production own-cli candidate + working-directory: rust + run: cargo build -p own-cli --release + - name: Run the Stage-2 campaign on Windows + run: | + export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli.exe" + python scripts/mutate_campaign.py --campaign docs/evidence/p022-stage2-1.json --run + - name: The Windows verdict must match the recorded Linux one + run: | + python - <<'PY' + import json, sys + run = json.load(open("docs/evidence/p022-stage2-1.result.json", encoding="utf-8")) + defn = json.load(open("docs/evidence/p022-stage2-1.json", encoding="utf-8")) + exp = {m["id"]: set(m["expected_catchers"]) for m in defn["mutations"]} + problems = [] + if run["control"]["outcome"] != "survived": + problems.append("the honesty control did not survive the unmutated tree") + for m in run["mutations"]: + if m["outcome"] != "caught": + problems.append(f"{m['id']}: {m['outcome']} on Windows") + elif not exp[m["id"]] <= set(m["catchers"]): + problems.append(f"{m['id']}: expected catchers missed ({m['catchers']})") + print("\n".join(problems) if problems + else f"Windows agrees: all {len(run['mutations'])} mutations caught, " + "each by the catcher its definition names") + sys.exit(1 if problems else 0) + PY + # Left uncommitted on purpose: one campaign has one recorded provenance, + # and it is the Linux run. A second file claiming the same campaign name + # would make "which tree was measured" ambiguous. + # + # Restore-or-remove, because the recorded result is tracked at some + # commits and not at others, and `git checkout --` on an untracked path + # is an error rather than a no-op — which is exactly how the first + # version of this step failed a job whose verdict had already agreed. + # The assertion is the point, not the cleanup: the step fails if this + # gate left a result behind. + - name: Confirm nothing was recorded from this run + if: always() + run: | + f=docs/evidence/p022-stage2-1.result.json + if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then + git checkout -- "$f" + else + rm -f "$f" + fi + test -z "$(git status --porcelain -- "$f")" \ + || { echo "FAIL: the Windows gate left a recorded result behind"; exit 1; } + echo "OK: the Windows verdict recorded nothing" + # P-022 step 7a (#260) — COMPARE MODE over the committed corpus: the FAST half # of #260's test matrix, and one leg of it. The five pinned OSS repositories, # the large-solution controls and the examples tree are the scheduled/manual @@ -2636,8 +2728,18 @@ jobs: # consumer-facing payoff the exporter was built for. The samples are intentional # leak fixtures, so the alerts are real-if-intentional; a dedicated # `own-net-samples` category keeps them from colliding with anything else. + # P-022 step 8 (#262) STAGE 2. This is the repository's dog-food — Own.NET + # analysing its own tree and publishing the result to its own code scanning — + # so under Stage 2 it runs on the RUST core, selected explicitly. + # + # Explicitly, and that is the whole design. The product default stays Python + # (Stage 3 is a separate authorization), so a job that asks for nothing gets + # Python; "the dogfood is Rust-default" therefore has to be written at the + # call site, where tests/test_stage2_dogfood.py can read it back. The Action's + # own public default is unaffected and is still exercised bare by + # own-check-surface and by the marketplace consumer simulation. own-check-codescan: - name: own-check SARIF -> GitHub code scanning (dog-food) + name: own-check SARIF -> GitHub code scanning (Rust dog-food) runs-on: ubuntu-latest # Skip on fork PRs: GitHub downgrades GITHUB_TOKEN to read-only for a # pull_request from a fork, so security-events:write is never granted and the @@ -2649,14 +2751,36 @@ jobs: permissions: contents: read security-events: write + # The candidate reaches the composite action through the environment, and + # job level rather than step level because that is the inheritance a + # composite action's own steps can be relied on to see. + env: + OWEN_RUST_CORE: ${{ github.workspace }}/rust/target/release/own-cli steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Own.NET leak check (SARIF surface) + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + # The PRODUCTION binary, built here, from this commit. Never + # own-shadow-engine (the #260 dev adapter) and never the Stage-1 test + # stub: a dogfood run through an instrument proves nothing about the + # thing being dogfooded. + - name: Build the production own-cli candidate + working-directory: rust + run: cargo build -p own-cli --release + - name: Record which candidate ran + run: | + test -x "$OWEN_RUST_CORE" || { echo "FAIL: no candidate at $OWEN_RUST_CORE"; exit 1; } + echo "candidate: $OWEN_RUST_CORE" + echo "sha256: $(sha256sum "$OWEN_RUST_CORE" | cut -d' ' -f1)" + echo "bytes: $(wc -c < "$OWEN_RUST_CORE")" + - name: Own.NET leak check (SARIF surface, Rust engine) id: own uses: ./ with: path: frontend/roslyn/samples format: sarif + engine: rust # STAGE 2: the dogfood runs on the Rust core severity: warning # include the injected-source (warning-tier) leaks fail-on-finding: "false" # let code scanning be the gate, not the step - name: The action exposes the SARIF path @@ -2671,6 +2795,105 @@ jobs: sarif_file: ${{ steps.own.outputs.sarif-file }} category: own-net-samples + # P-022 step 8 (#262) STAGE 2 — the platform half of the Rust-default claim. + # + # own-check-codescan is the dogfood of record, but it is ubuntu-only and + # uploads a single code-scanning category, so it cannot carry Windows. The + # two launcher surfaces differ in exactly the mechanics that cost Stage 1 six + # CI rounds — process launch, executable bits, path forms, stream capture — + # so a Linux-only "our CI runs on Rust" is a claim about half the product. + # + # This job is deliberately NOT a contract test. stage1-engine already proves + # the engine contract; this one only asks the operational question: does this + # repository's own tree analyse correctly, through the shipped launchers, + # with the Rust core explicitly selected, on both platforms. + stage2-dogfood: + name: Rust-default dogfood (Own.NET's own tree, via own-cli) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: "8.0.x" + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + - name: Build the production own-cli candidate + working-directory: rust + run: cargo build -p own-cli --release + - name: Record which candidate ran + run: | + ext="" + if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi + core="$PWD/rust/target/release/own-cli$ext" + test -f "$core" || { echo "FAIL: no candidate at $core"; exit 1; } + echo "candidate: $core" + echo "sha256: $(sha256sum "$core" | cut -d' ' -f1)" + echo "bytes: $(wc -c < "$core")" + # The operational run. --fail-on-finding is deliberate: the dogfood tree + # HAS a leak, so exit 1 is the correct answer and exit 0 would mean the + # Rust core analysed nothing and said so quietly. + - name: Own.NET's own tree, through own-check.sh on the Rust core + run: | + ext="" + if [ "${{ matrix.os }}" = "windows-latest" ]; then ext=".exe"; fi + export OWEN_RUST_CORE="$PWD/rust/target/release/own-cli$ext" + set +e + out=$(bash scripts/own-check.sh --engine rust --format human --fail-on-finding \ + -- frontend/roslyn/samples) + rc=$? + set -e + echo "$out" + [ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 (findings), got $rc"; exit 1; } + case "$out" in *OWN001*) ;; *) echo "FAIL: the Rust core found no OWN001"; exit 1 ;; esac + echo "OK: Rust-default dogfood on ${{ matrix.os }} (own-check.sh)" + # The Windows launcher is a separate implementation, not a wrapper around + # the shell one, so the Windows leg has to go through it to mean anything. + - name: Own.NET's own tree, through own-check.ps1 on the Rust core + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + $env:OWEN_RUST_CORE = "$PWD/rust/target/release/own-cli.exe" + $out = & ./scripts/own-check.ps1 -Engine rust -Format human -FailOnFinding ` + -Paths frontend/roslyn/samples + $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" } + Write-Host "OK: Rust-default dogfood on windows (own-check.ps1)" + exit 0 + # No fallback, measured rather than asserted: with the candidate broken, + # the dogfood must fail visibly. A run that quietly produced a verdict + # here would mean Python had answered for Rust, and every green Rust + # dogfood above would be worth nothing. + - name: A broken candidate fails the dogfood instead of being rescued + run: | + broken="$RUNNER_TEMP/not-a-core" + printf 'this is not an executable image\n' > "$broken" + export OWEN_RUST_CORE="$broken" + set +e + out=$(bash scripts/own-check.sh --engine rust --format human \ + -- frontend/roslyn/samples 2>&1) + rc=$? + set -e + case "$out" in + *OWN001*) + echo "FAIL: a verdict was produced with a broken candidate — Python answered for Rust" + exit 1 ;; + esac + [ "$rc" -ne 0 ] || { echo "FAIL: a broken candidate exited 0"; exit 1; } + echo "OK: a broken candidate is a visible failure (exit $rc), never a Python rescue" + # P-014 Tier B: external-reference resolution. The SAME sample, run two ways, must give two # verdicts — proving the extractor binds a THIRD-PARTY event only when its DLL is referenced: # A (no refs) -> ObservableObject is an error type -> OWN050 (honest skip), no leak diff --git a/docs/evidence/p022-stage2-1.json b/docs/evidence/p022-stage2-1.json new file mode 100644 index 00000000..561d4e9a --- /dev/null +++ b/docs/evidence/p022-stage2-1.json @@ -0,0 +1,148 @@ +{ + "schema": 1, + "comment": "GENERATED-BY-HAND definition; the RESULT beside it is recorded by scripts/mutate_campaign.py --run and the counts are derived from it, never typed.", + "campaign": "p022-stage2-1", + "description": "#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.", + "layers_comment": "One layer, and the RECORDED run is Linux. Every mutation here targets declarative text — a workflow key, a ledger entry, a C# constant, an action input — and every catcher reads that text, so the verdicts should not differ by platform. They are MEASURED on both anyway, by the `stage2-windows-mutations` gate, because 'should not differ' is exactly the reasoning that failed here: the controls themselves passed on Linux and failed on Windows, where a path key built with the host separator missed every ledger entry and read all twenty call sites as unclassified. That was a defect in the harness rather than in any mutant, so no Linux campaign could have reported it, and arguing platform independence would have shipped it. The Windows job records nothing: one campaign has one recorded provenance. This is a different situation from the Stage-1 PowerShell mutants, which change semantics only a Windows runtime can evaluate and therefore keep their own recorded Windows-native campaign.", + "layers": [ + { + "id": "stage2", + "cwd": ".", + "parser": "python-fail", + "command": [ + "python", + "tests/test_stage2_dogfood.py" + ], + "env": { + "OWEN_STAGE2_REQUIRE": "1" + } + } + ], + "control": { + "id": "M00", + "description": "harness-honesty control: no mutation at all, which must report zero failing layers" + }, + "mutations": [ + { + "id": "S01", + "rule": "dogfood-selects-rust-explicitly", + "description": "the dog-food job stops asking for an engine — the most likely regression of all, because the job still runs, still analyses, still uploads, and silently goes back to the public Python default", + "target": ".github/workflows/ci.yml", + "pattern": " engine: rust # STAGE 2: the dogfood runs on the Rust core\n", + "replacement": "", + "expected_catchers": [ + "stage2::internal-default-not-rust" + ] + }, + { + "id": "S02", + "rule": "dogfood-runs-the-production-core", + "description": "the dog-food candidate becomes own-shadow-engine — the #260 dev adapter is a Rust binary that this repository really builds, so the substitution looks right and measures a thing that is not the product", + "target": ".github/workflows/ci.yml", + "pattern": " OWEN_RUST_CORE: \\$\\{\\{ github\\.workspace \\}\\}/rust/target/release/own-cli", + "replacement": " OWEN_RUST_CORE: ${{ github.workspace }}/rust/target/release/own-shadow-engine", + "expected_catchers": [ + "stage2::wrong-rust-candidate" + ] + }, + { + "id": "S03", + "rule": "the-claim-covers-both-platforms", + "description": "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", + "target": ".github/workflows/ci.yml", + "pattern": " name: Rust-default dogfood \\(Own\\.NET's own tree, via own-cli\\)\n strategy:\n fail-fast: false\n matrix:\n os: \\[ubuntu-latest, windows-latest\\]", + "replacement": " name: Rust-default dogfood (Own.NET's own tree, via own-cli)\n strategy:\n fail-fast: false\n matrix:\n os: [ubuntu-latest]", + "expected_catchers": [ + "stage2::platform-leg-lost" + ] + }, + { + "id": "S04", + "rule": "the-locator-is-owen-rust-core-alone", + "description": "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", + "target": ".github/workflows/ci.yml", + "pattern": " core=\"\\$PWD/rust/target/release/own-cli\\$ext\"", + "replacement": " core=$(which own-cli || echo \"$PWD/rust/target/release/own-cli$ext\")", + "expected_catchers": [ + "stage2::locator-contract-bypassed" + ] + }, + { + "id": "S05", + "rule": "a-rust-failure-is-visible", + "description": "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", + "target": ".github/workflows/ci.yml", + "pattern": " out=\\$\\(bash scripts/own-check\\.sh --engine rust --format human --fail-on-finding \\\\\n -- frontend/roslyn/samples\\)", + "replacement": " out=$(bash scripts/own-check.sh --engine rust --format human --fail-on-finding -- frontend/roslyn/samples || true)", + "expected_catchers": [ + "stage2::rust-job-falls-back" + ] + }, + { + "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", + "target": "frontend/roslyn/OwnSharp.Cli/EngineSelection.cs", + "pattern": "public const Engine Default = Engine\\.Python;", + "replacement": "public const Engine Default = Engine.Rust;", + "expected_catchers": [ + "stage2::public-default-moved" + ] + }, + { + "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", + "target": "action.yml", + "pattern": " required: false\n default: \"python\"", + "replacement": " required: false\n default: \"rust\"", + "expected_catchers": [ + "stage2::public-default-moved" + ] + }, + { + "id": "S08", + "rule": "compare-evidence-is-not-traded-away", + "description": "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", + "target": ".github/workflows/ci.yml", + "pattern": " shadow-compare:\n name: shadow compare \\(committed corpus\\)\n", + "replacement": " shadow-compare:\n name: shadow compare (committed corpus)\n if: false\n", + "expected_catchers": [ + "stage2::compare-gate-dropped" + ] + }, + { + "id": "S09", + "rule": "no-unclassified-call-site", + "description": "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", + "target": ".github/workflows/ci.yml", + "pattern": " - name: Check the emitted method is still in sync with the golden host\n", + "replacement": " - name: Check the emitted method is still in sync with the golden host\n run: scripts/own-check.sh --format human -- frontend/roslyn/samples\n", + "expected_catchers": [ + "stage2::stage2-census" + ] + }, + { + "id": "S10", + "rule": "no-escape-by-relabelling", + "description": "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", + "target": "docs/evidence/p022-stage2-census.json", + "pattern": " \"job\": \"own-check-codescan\",\n \"class\": \"D\",", + "replacement": " \"job\": \"own-check-codescan\",\n \"class\": \"A\",", + "expected_catchers": [ + "stage2::stage2-census" + ] + }, + { + "id": "S11", + "rule": "dogfood-selects-rust-explicitly", + "description": "the dog-food's engine selection is COMMENTED OUT rather than deleted — the shape a temporary rollback really takes ('just for one run, we'll put it back'). Its twin S01 deletes the line; this one leaves the words `engine: rust` sitting in the file, which is all a control that greps the job's raw text would need to stay green. It exists because S03 survived for exactly that reason on a different key", + "target": ".github/workflows/ci.yml", + "pattern": " engine: rust # STAGE 2: the dogfood runs on the Rust core", + "replacement": " # engine: rust # STAGE 2: the dogfood runs on the Rust core", + "expected_catchers": [ + "stage2::internal-default-not-rust" + ] + } + ] +} diff --git a/docs/evidence/p022-stage2-1.result.json b/docs/evidence/p022-stage2-1.result.json new file mode 100644 index 00000000..44c38734 --- /dev/null +++ b/docs/evidence/p022-stage2-1.result.json @@ -0,0 +1,110 @@ +{ + "schema": 1, + "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", + "dirty": false, + "recorded_at": "2026-09-09T15:14:43Z", + "layers": [ + "stage2" + ], + "command": "every layer the definition declares, for every mutation", + "control": { + "id": "M00", + "outcome": "survived", + "catchers": [], + "elapsed_seconds": 8.6 + }, + "mutations": [ + { + "id": "S01", + "outcome": "caught", + "catchers": [ + "stage2::internal-default-not-rust" + ], + "elapsed_seconds": 6.0 + }, + { + "id": "S02", + "outcome": "caught", + "catchers": [ + "stage2::wrong-rust-candidate" + ], + "elapsed_seconds": 6.0 + }, + { + "id": "S03", + "outcome": "caught", + "catchers": [ + "stage2::platform-leg-lost" + ], + "elapsed_seconds": 6.0 + }, + { + "id": "S04", + "outcome": "caught", + "catchers": [ + "stage2::locator-contract-bypassed" + ], + "elapsed_seconds": 6.1 + }, + { + "id": "S05", + "outcome": "caught", + "catchers": [ + "stage2::rust-job-falls-back" + ], + "elapsed_seconds": 6.3 + }, + { + "id": "S06", + "outcome": "caught", + "catchers": [ + "stage2::public-default-moved" + ], + "elapsed_seconds": 6.1 + }, + { + "id": "S07", + "outcome": "caught", + "catchers": [ + "stage2::public-default-moved" + ], + "elapsed_seconds": 6.3 + }, + { + "id": "S08", + "outcome": "caught", + "catchers": [ + "stage2::compare-gate-dropped" + ], + "elapsed_seconds": 6.3 + }, + { + "id": "S09", + "outcome": "caught", + "catchers": [ + "stage2::stage2-census" + ], + "elapsed_seconds": 6.0 + }, + { + "id": "S10", + "outcome": "caught", + "catchers": [ + "stage2::stage2-census" + ], + "elapsed_seconds": 6.1 + }, + { + "id": "S11", + "outcome": "caught", + "catchers": [ + "stage2::internal-default-not-rust" + ], + "elapsed_seconds": 5.9 + } + ] +} diff --git a/docs/evidence/p022-stage2-census.json b/docs/evidence/p022-stage2-census.json new file mode 100644 index 00000000..34203fbd --- /dev/null +++ b/docs/evidence/p022-stage2-census.json @@ -0,0 +1,151 @@ +{ + "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.", + "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.", + "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." + }, + "entry_points_comment": "What counts as executing the core. The launcher surfaces are direct; the wrapper entries are scripts that invoke a launcher or the core themselves, listed so an indirect call site cannot escape the census by adding one level of indirection. Two negative lookaheads are deliberate and were measured, not guessed: `benchmark.py --selftest` validates the scoring and SARIF-parsing logic against embedded fixtures with no SDK, and `shadow_sweep.py --collect`/`--result` assemble and check a record of legs that ran elsewhere. Neither executes the analysis core, so neither is a call site; counting them would inflate the denominator with runs that analyse nothing. Comment lines and `paths:` filters are stripped for the same reason.", + "entry_points": [ + "scripts/own-check\\.sh", + "scripts/own-check\\.ps1", + "\\bowen check\\b", + "python -m ownlang ownir", + "uses: \\./\\s*$", + "scripts/shadow_compare\\.py", + "scripts/benchmark\\.py(?!.*--selftest)", + "tests/shadow_sweep\\.py(?!.*(--collect|--result))", + "cargo test[^\\n]*-p own-cli", + "scripts/mutate_campaign\\.py" + ], + "call_sites": [ + { + "workflow": ".github/workflows/ci.yml", + "job": "own-cli-parity", + "class": "B", + "why": "Replays the frozen Python-authored CLI fixture family against the built Rust `own-cli` with zero Python at run time. The measurement IS the parity between the two implementations, so it is engine-specific by construction and has no default to move." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "stage1-engine", + "class": "A", + "why": "The Stage-1 engine-contract controls, including `default-stays-python`, which is literally the public-default assertion. It selects Rust in some legs, but as the SUBJECT of a contract test, not as the repository's operational engine — the authorization says so explicitly. Judgement call recorded rather than hidden: it sits in A because the contract it verifies is the public one." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "stage1-windows-mutations", + "class": "A", + "why": "The Windows-native mutation leg over those same controls: mutate_campaign.py drives the Stage-1 harnesses, which drive the launchers. Same role as stage1-engine — it proves the controls are load-bearing, it does not analyse Own.NET's code." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "stage2-windows-mutations", + "class": "A", + "why": "The Windows verdict on the Stage-2 campaign, as a gate. It drives the Stage-2 controls through mutate_campaign.py, so it exercises the launchers the way stage1-windows-mutations does — a contract measurement, not operational analysis, and it records nothing." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "shadow-compare", + "class": "C", + "why": "#260 compare mode over the committed corpus, through the dev-only own-shadow-engine adapter." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "shadow-compare-samples", + "class": "C", + "why": "#260 compare mode over the extracted C# sample facts." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "wpf-extractor", + "class": "B", + "why": "P-001 pipeline semantics over fixture samples, asserting exact reference output. It calls `python -m ownlang ownir` DIRECTLY — the reference core, not a launcher — so there is no engine selector here to default: the Python-ness is the instrument, not an omission." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "ownts-react-effects", + "class": "B", + "why": "The OwnTS frontend spike over the same seam, also calling the reference core directly and asserting its exact verdicts (OWN001, EFF001)." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "own-check-surface", + "class": "A", + "why": "The distribution surface: bare `scripts/own-check.sh` in four formats plus five composite-action steps that pass NO engine input, which is exactly how a consumer invokes it. This job is one of the two places the Action's public default is exercised end to end." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "own-check-ps1-surface", + "class": "A", + "why": "The Windows wrapper's public exit-code tiers (#313), invoked bare." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "own-check-codescan", + "class": "D", + "why": "The repository's own dog-food: Own.NET analysing its own tree and publishing the result to its own code scanning. Labelled dog-food in its own name and the only pre-Stage-2 Class-D call site, so this is the one that had to move to Rust." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "stage2-dogfood", + "class": "D", + "why": "The Stage-2 Rust-default dogfood matrix. It exists because own-check-codescan is Linux-only and uploads one code-scanning category, so it cannot carry the Windows half of the claim; this job runs the repository's own tree through the shell launcher on Linux and the PowerShell launcher on Windows with an explicit `rust` selection." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "tier-b-refs", + "class": "B", + "why": "P-014 Tier B A/B: the SAME sample analysed twice to prove external-reference binding changes the verdict. The reference core is called directly and the assertion is on its exact codes." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "corpus-benchmark", + "class": "B", + "why": "Benchmark methodology. `scripts/benchmark.py` drives own-check.sh over the labelled corpus and produces a regression-pinned recall/specificity number; changing the engine underneath silently redefines what that pinned number means and breaks comparability with every earlier run." + }, + { + "workflow": ".github/workflows/ci.yml", + "job": "ownsharp-cli-smoke", + "class": "A", + "why": "Gate A: bare `owen check` on an INSTALLED build outside any checkout, plus the OWEN_PYTHON-broken cases that prove Python resolution is real. The packaged public surface." + }, + { + "workflow": ".github/workflows/owen-cli-release.yml", + "job": "smoke-test", + "class": "A", + "why": "The release package's own smoke test: bare `owen check` against the built artifact, including the broken-interpreter case. The public contract as shipped." + }, + { + "workflow": ".github/workflows/action-marketplace-readiness.yml", + "job": "consumer-simulation", + "class": "A", + "why": "Six `uses: ./` steps with no engine input — the Action consumed the way a marketplace consumer consumes it. The second place the public Action default is exercised end to end." + }, + { + "workflow": ".github/workflows/oracle.yml", + "job": "oracle", + "class": "B", + "why": "Cross-tool evaluation against Infer# and CodeQL over an external repository, manual dispatch only. The agreement report's history is only comparable if Own.NET's side keeps producing it with the reference engine." + }, + { + "workflow": ".github/workflows/shadow-sweep.yml", + "job": "document", + "class": "C", + "why": "The #260 sweep: own-check.sh is the single EXTRACTION per document that feeds the compare, and the compare is the gate." + }, + { + "workflow": ".github/workflows/shadow-sweep.yml", + "job": "examples", + "class": "C", + "why": "The same #260 sweep over the `examples/` tree: one extraction per document through own-check.sh, feeding the same compare gate." + }, + { + "workflow": ".github/workflows/shadow-sweep.yml", + "job": "windows-path-forms", + "class": "C", + "why": "The Windows leg of the same compare gate." + } + ] +} diff --git a/docs/generated/p022-stage2-mutations.md b/docs/generated/p022-stage2-mutations.md new file mode 100644 index 00000000..f3ff60d8 --- /dev/null +++ b/docs/generated/p022-stage2-mutations.md @@ -0,0 +1,38 @@ + + +# P-022 step 8 (#262) Stage 2 — mutation campaigns + +Stage 2 moves nothing a user can see: it makes THIS repository's CI and dogfood run on the Rust core while all four public surfaces keep resolving Python. The deliverable is therefore a configuration, and so are the mutations — a workflow key, a ledger entry, an action input, one C# constant. Each is a plausible way the Rust-default claim could quietly stop being true, or the public default could quietly start moving, with every job still green: the dog-food job stops naming an engine and falls back to the public default; its candidate becomes the dev-only compare adapter; the matrix loses Windows; the locator is found on PATH; the run swallows its own exit code; a compare gate is disabled so nothing is left to disagree; a new bare invocation appears in a job nobody classified; and the dog-food job escapes the Rust-default population by being relabelled in the census rather than changed. The catchers are the controls in `tests/test_stage2_dogfood.py`, which enumerate the workflows themselves — a census that could be satisfied by editing prose would be worth nothing. The counts are derived from the recorded run by `scripts/mutate_campaign.summarize()`, never typed. + +## Stage 2 — Own.NET's own CI and dogfood select Rust, and the public contract does not move + +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. + +| measure | value | +|--------------------------------------------------|---| +| recorded at commit | `345cf04fe9f9880a2b316f220b565412e9cb9855` | +| layers run (every one, for every mutation) | `stage2` | +| mutations | 11 | +| caught | 11 | +| survived | 0 | +| compile-error (no evidence either way) | 0 | +| invalid-mutation | 0 | +| runner-error | 0 | +| caught without every expected catcher | none | +| honesty control `M00` (unmutated tree must pass) | survived — as required | + +| id | rule | mutation | outcome | caught by | +|---|---|---|---|---| +| S01 | dogfood-selects-rust-explicitly | the dog-food job stops asking for an engine — the most likely regression of all, because the job still runs, still analyses, still uploads, and silently goes back to the public Python default | caught | `stage2::internal-default-not-rust` | +| S02 | dogfood-runs-the-production-core | the dog-food candidate becomes own-shadow-engine — the #260 dev adapter is a Rust binary that this repository really builds, so the substitution looks right and measures a thing that is not the product | caught | `stage2::wrong-rust-candidate` | +| 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` | +| 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` | +| S11 | dogfood-selects-rust-explicitly | the dog-food's engine selection is COMMENTED OUT rather than deleted — the shape a temporary rollback really takes ('just for one run, we'll put it back'). Its twin S01 deletes the line; this one leaves the words `engine: rust` sitting in the file, which is all a control that greps the job's raw text would need to stay green. It exists because S03 survived for exactly that reason on a different key | caught | `stage2::internal-default-not-rust` | diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index 17733f03..4c76f48d 100644 --- a/docs/proposals/P-022-rust-core-migration.md +++ b/docs/proposals/P-022-rust-core-migration.md @@ -76,9 +76,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.** 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: 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 | -**Preferred queue:** **#262 is in progress — Stage 1 (opt-in Rust behind the launcher) landed; Stage 2 is next and needs its own authorization.** #261's production OwnIR executable is +**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 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 bad0febf..d189d5d2 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** 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 1 is explicitly not the cutover, which stays behind Gate G3. 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) 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-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/scripts/render_checkpoint_status.py b/scripts/render_checkpoint_status.py index 54ba8ae1..0c23f8e6 100644 --- a/scripts/render_checkpoint_status.py +++ b/scripts/render_checkpoint_status.py @@ -135,6 +135,7 @@ CLI_CENSUS_MD = "p022-cli-census.md" CLI_MUTATIONS_MD = "p022-cli-mutations.md" STAGE1_MUTATIONS_MD = "p022-stage1-mutations.md" +STAGE2_MUTATIONS_MD = "p022-stage2-mutations.md" SHADOW_CENSUS_MD = "p022-shadow-census.md" SHADOW_MUTATIONS_MD = "p022-shadow-mutations.md" SHADOW_SWEEP_MD = "p022-shadow-sweep.md" @@ -199,6 +200,15 @@ "catcher", "p022-stage1-windows"), ) + +# Stage 2 (#262) — the CI/dogfood engine-SELECTION seam. Its deliverable is a +# configuration, so its mutants are configuration and its catchers read the +# workflows rather than being told about them. +STAGE2_CAMPAIGNS = ( + ("Stage 2 — Own.NET's own CI and dogfood select Rust, and the public contract " + "does not move", + "p022-stage2-1"), +) SELF = "scripts/render_checkpoint_status.py" @@ -1224,6 +1234,27 @@ def fragments() -> tuple[dict[str, str], list[str]]: STAGE1_CAMPAIGNS) out[STAGE1_MUTATIONS_MD] = stage1 problems.extend(f"mutation campaign {p}" for p in stage1_problems) + stage2, stage2_problems = render_campaign_set( + "# P-022 step 8 (#262) Stage 2 — mutation campaigns", + "Stage 2 moves nothing a user can see: it makes THIS repository's CI and " + "dogfood run on the Rust core while all four public surfaces keep resolving " + "Python. The deliverable is therefore a configuration, and so are the " + "mutations — a workflow key, a ledger entry, an action input, one C# constant. " + "Each is a plausible way the Rust-default claim could quietly stop being true, " + "or the public default could quietly start moving, with every job still green: " + "the dog-food job stops naming an engine and falls back to the public default; " + "its candidate becomes the dev-only compare adapter; the matrix loses Windows; " + "the locator is found on PATH; the run swallows its own exit code; a compare " + "gate is disabled so nothing is left to disagree; a new bare invocation appears " + "in a job nobody classified; and the dog-food job escapes the Rust-default " + "population by being relabelled in the census rather than changed. The catchers " + "are the controls in `tests/test_stage2_dogfood.py`, which enumerate the " + "workflows themselves — a census that could be satisfied by editing prose would " + "be worth nothing. The counts are derived from the recorded run by " + "`scripts/mutate_campaign.summarize()`, never typed.", + STAGE2_CAMPAIGNS) + out[STAGE2_MUTATIONS_MD] = stage2 + problems.extend(f"mutation campaign {p}" for p in stage2_problems) return out, problems @@ -1270,7 +1301,7 @@ def main(argv: list[str]) -> int: print(f"checkpoint status fragments OK: {CENSUS_MD}, {CP1_CENSUS_MD}, " f"{COORD_CENSUS_MD}, {INVENTORY_MD}, {MUTATIONS_MD}, {CP5_MUTATIONS_MD}, " f"{SHADOW_CENSUS_MD}, {SHADOW_MUTATIONS_MD}, {SHADOW_SWEEP_MD}, " - f"{STAGE1_MUTATIONS_MD} in sync with the evidence") + f"{STAGE1_MUTATIONS_MD}, {STAGE2_MUTATIONS_MD} in sync with the evidence") return 0 diff --git a/tests/test_stage2_dogfood.py b/tests/test_stage2_dogfood.py new file mode 100644 index 00000000..73aa5a85 --- /dev/null +++ b/tests/test_stage2_dogfood.py @@ -0,0 +1,734 @@ +#!/usr/bin/env python3 +"""#262 Stage 2 — Own.NET's own CI and dogfood select Rust, and the public +contract does not move. + +Stage 1 proved the Rust core works behind the launcher. Stage 2 proves only +that this repository can RUN on it internally, and that doing so moved nothing +a user can see. That is a claim about the CI configuration, so most of the +evidence here is a claim about the CI configuration, read from the workflows +themselves rather than asserted in prose. + +Why a census and not a demonstration. "Own.NET CI is Rust-default" is +unfalsifiable without a denominator: one Rust job proves one Rust job. So +`docs/evidence/p022-stage2-census.json` names EVERY call site in every workflow +that executes the analysis core or a production launcher surface, and the role +each one plays (A public-contract verifier / B explicit reference / C compare +gate / D operational dogfood). This harness enumerates the workflows itself and +fails when the two disagree in EITHER direction — an unclassified call site, or +a ledger entry for a job that no longer invokes anything. A census that can be +satisfied by editing prose is not a census. + +The controls, and the direction each one guards: + + 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 + 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 + python-reference-lost the explicit reference path still exists and runs + compare-gate-dropped no compare gate was traded for Rust exposure + platform-leg-lost the Rust-default claim covers Linux AND Windows + +Failures print `FAIL[]: `; nothing stops at the first one, so a +mutation campaign sees every catcher it trips rather than the first. + +Run: python tests/test_stage2_dogfood.py +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +LEDGER = ROOT / "docs/evidence/p022-stage2-census.json" +WORKFLOWS = ROOT / ".github/workflows" + +SAMPLE_CS = """using System; +using System.IO; + +public class Leaky +{ + public void Run() + { + var s = new FileStream("x.txt", FileMode.OpenOrCreate); + Console.WriteLine(s.Length); + } +} +""" + +_FAILURES: list[tuple[str, str]] = [] +_PASSES: list[str] = [] +_SKIPS: list[tuple[str, str]] = [] + + +def fail(check: str, detail: str) -> None: + _FAILURES.append((check, detail)) + print(f"FAIL[{check}]: {detail}") + + +def ok(check: str, detail: str = "") -> None: + _PASSES.append(check) + print(f"ok[{check}]: {detail}" if detail else f"ok[{check}]") + + +def skip(check: str, why: str) -> None: + """Unrunnable here. Required in CI: OWEN_STAGE2_REQUIRE=1 turns every skip + into a failure, so a gate cannot go green by quietly measuring nothing.""" + if os.environ.get("OWEN_STAGE2_REQUIRE"): + fail(check, f"required but unrunnable: {why}") + else: + _SKIPS.append((check, why)) + print(f"skip[{check}]: {why}") + + +def tail(r: subprocess.CompletedProcess[bytes], limit: int = 300) -> str: + out = (r.stdout + r.stderr).decode("utf-8", "replace").strip().replace("\n", " | ") + return out[-limit:] if len(out) > limit else out + + +# --- the workflow reader --------------------------------------------------- + + +def ledger() -> dict[str, object]: + return json.loads(LEDGER.read_text(encoding="utf-8")) + + +def _strip_comment(line: str) -> str: + """A `#` comment is not a call site. + + Only whole-line comments are dropped. A `#` inside a shell line can be a + real comment too, but it can equally be a fragment of a command, and + guessing wrong in the direction of DROPPING text would let a call site hide + behind a hash. Whole-line only, deliberately conservative. + """ + return "" if line.strip().startswith("#") else line + + +def _is_path_filter(line: str) -> bool: + """`- "scripts/own-check.sh"` under `paths:` names a file to WATCH, not a + command to run.""" + s = line.strip() + return bool(re.match(r'^-\s*["\']?[\w./*-]+["\']?$', s)) + + +def job_spans(path: Path) -> list[tuple[str, int, int]]: + """(job id, first line, last line) for every job in a workflow, 1-based. + + Job ids are the two-space keys AFTER `jobs:` — the same shape as `push:` + under `on:`, which is why the `jobs:` boundary is required rather than + assumed. + """ + lines = path.read_text(encoding="utf-8").splitlines() + start = next((i for i, ln in enumerate(lines) if ln.rstrip() == "jobs:"), None) + if start is None: + return [] + heads: list[tuple[str, int]] = [] + for i in range(start + 1, len(lines)): + m = re.match(r"^ ([A-Za-z0-9][A-Za-z0-9_-]*):\s*$", lines[i]) + if m: + heads.append((m.group(1), i + 1)) + spans = [] + for n, (job, first) in enumerate(heads): + last = heads[n + 1][1] - 1 if n + 1 < len(heads) else len(lines) + spans.append((job, first, last)) + return spans + + +def job_text(path: Path, first: int, last: int) -> str: + lines = path.read_text(encoding="utf-8").splitlines() + return "\n".join(lines[first - 1:last]) + + +def job_code(path: Path, first: int, last: int) -> str: + """The job with its whole-line comments removed. + + Every control below that asks "does this job do X" must ask it of the code + and not of the prose beside it, or a mutation that comments X out leaves + the words in place and the control green. + """ + return "\n".join(_strip_comment(ln) for ln in job_text(path, first, last).splitlines()) + + +def runner_oses(path: Path, first: int, last: int) -> set[str]: + """The runner images this job ACTUALLY runs on. + + Not "does the string windows-latest appear somewhere in it". That was the + first version, and a mutation that deleted windows-latest from the matrix + SURVIVED it: the `if: matrix.os == 'windows-latest'` guards left behind + still contained the word, so a job that could no longer run on Windows + still looked like one that did. The lesson is the same one this repository + keeps paying for — a validator must read the thing it means. + + Two shapes are read, because two are used here: a literal `runs-on`, and a + `runs-on: ${{ matrix.os }}` resolved through `strategy.matrix.os` in either + its flow or its block form. Anything else returns empty, which fails the + caller rather than passing it. + """ + lines = job_code(path, first, last).splitlines() + runs_on: str | None = None + matrix: list[str] = [] + in_os_block = False + for ln in lines: + m = re.match(r"^ runs-on:\s*(\S.*?)\s*$", ln) + if m: + runs_on = m.group(1) + m = re.match(r"^\s{6,}os:\s*\[(.+)\]\s*$", ln) + if m: + matrix = [x.strip().strip("'\"") for x in m.group(1).split(",") if x.strip()] + in_os_block = False + continue + if re.match(r"^\s{6,}os:\s*$", ln): + in_os_block = True + continue + if in_os_block: + m = re.match(r"^\s+-\s*['\"]?([A-Za-z0-9._-]+)['\"]?\s*$", ln) + if m: + matrix.append(m.group(1)) + elif ln.strip(): + in_os_block = False + if runs_on is None: + return set() + return set(matrix) if "matrix." in runs_on else {runs_on} + + +def platform_of(image: str) -> str | None: + low = image.lower() + if "windows" in low: + return "windows" + if "ubuntu" in low or "linux" in low: + return "linux" + if "macos" in low: + return "macos" + return None + + +def enumerate_call_sites() -> tuple[dict[tuple[str, str], list[str]], list[str]]: + """Every (workflow, job) that executes the core or a launcher surface. + + Returns the map and the entry-point patterns used, so the report can say + what was searched for rather than only what was found. + """ + patterns = [re.compile(p) for p in ledger()["entry_points"]] # type: ignore[index] + found: dict[tuple[str, str], list[str]] = {} + for wf in sorted(WORKFLOWS.glob("*.yml")): + # as_posix(), not str(). On Windows str() yields `.github\\workflows\\ci.yml` + # while the ledger stores forward slashes, so every key missed: every + # call site read as unclassified and every ledger entry as stale, and + # the census failed on Windows while passing on Linux. This repository + # has paid for a host path separator once already — #260's mutation + # harness took the separator into a catcher NAME and reported five + # protected rules as unprotected. The ledger is a committed artefact + # shared by both platforms, so its keys are POSIX by definition. + rel = wf.relative_to(ROOT).as_posix() + lines = wf.read_text(encoding="utf-8").splitlines() + for job, first, last in job_spans(wf): + hits = [] + for n in range(first - 1, min(last, len(lines))): + line = _strip_comment(lines[n]) + if not line or _is_path_filter(line): + continue + for p in patterns: + if p.search(line): + hits.append(f"{rel}:{n + 1}: {line.strip()[:90]}") + break + if hits: + found[(rel, job)] = hits + return found, [str(p.pattern) for p in patterns] + + +def classified() -> dict[tuple[str, str], dict[str, str]]: + return {(c["workflow"], c["job"]): c for c in ledger()["call_sites"]} # type: ignore[index,union-attr] + + +def sites_of_class(cls: str) -> dict[tuple[str, str], dict[str, str]]: + return {k: v for k, v in classified().items() if v["class"] == cls} + + +# --- toolchain ------------------------------------------------------------- + + +def rust_core() -> str | None: + p = os.environ.get("OWEN_RUST_CORE") + return p if p and Path(p).is_file() else None + + +def launcher_dll() -> str | None: + p = os.environ.get("OWEN_STAGE1_LAUNCHER_DLL") + return p if p and Path(p).is_file() else None + + +def have_dotnet() -> bool: + return shutil.which("dotnet") is not None + + +def bash_exe() -> str: + """The bash that can run own-check.sh — never WSL's System32 stub.""" + if os.name != "nt": + return "bash" + for c in (os.environ.get("SHELL"), + r"C:\Program Files\Git\bin\bash.exe", + r"C:\Program Files\Git\usr\bin\bash.exe"): + if c and Path(c).is_file(): + return c + return "bash" + + +# --- controls: the census --------------------------------------------------- + + +def control_census() -> None: + """Every call site is classified, and every classification is still live. + + Both directions matter. A new bare invocation that nobody classified is the + hole this whole ledger exists to close; a stale entry is how a census keeps + claiming coverage of a job that no longer analyses anything. + """ + check = "stage2-census" + found, patterns = enumerate_call_sites() + known = classified() + problems = [] + + for key, hits in sorted(found.items()): + if key not in known: + problems.append(f"UNCLASSIFIED call site {key[0]}::{key[1]} — {hits[0]}") + for key in sorted(known): + if key not in found: + problems.append(f"STALE ledger entry {key[0]}::{key[1]}: no call site found there " + "any more; re-classify or remove it") + for key, entry in sorted(known.items()): + if entry["class"] not in ledger()["classes"]: # type: ignore[operator] + problems.append(f"{key[1]}: unknown class {entry['class']!r}") + if len(entry.get("why", "")) < 40: + problems.append(f"{key[1]}: classified without a justification") + + # The relabel escape, closed. Every rule above is satisfied by moving a job + # OUT of Class D: the Rust-default population shrinks, every remaining + # member still selects Rust, and the census still balances. So a job that + # calls itself dog-food in its own display name is Class D by declaration, + # and the ledger does not get to disagree with the workflow about what the + # job is for. + for wf_rel, job in sorted(found): + path = ROOT / wf_rel + span = next((sp for sp in job_spans(path) if sp[0] == job), None) + if span is None: + continue + m = re.search(r"^ name: (.+)$", job_code(path, span[1], span[2]), re.M) + label = (m.group(1) if m else "").lower() + if re.search(r"dog[- ]?food", label) and known.get((wf_rel, job), {}).get("class") != "D": + problems.append(f"{wf_rel}::{job} calls itself dog-food but is classified " + f"{known.get((wf_rel, job), {}).get('class')!r} — a job cannot " + "leave the Rust-default population by being relabelled") + + if problems: + fail(check, "; ".join(problems)) + else: + counts = {c: len(sites_of_class(c)) for c in ("A", "B", "C", "D")} + ok(check, f"{len(found)} core/launcher call sites across " + f"{len({k[0] for k in found})} workflows, all classified " + f"(A={counts['A']} public-contract, B={counts['B']} reference, " + f"C={counts['C']} compare, D={counts['D']} dogfood), " + f"{len(patterns)} entry-point patterns") + + +# --- controls: the Rust-default population --------------------------------- + + +_RUST_SELECTORS = (r"--engine rust", r"-Engine rust", r"engine: rust") + + +def control_internal_default_not_rust() -> None: + """Every Class-D call site selects Rust EXPLICITLY. + + Explicitly, because Stage 2 is not allowed to work by moving a default: the + product default stays Python, so an internal job that says nothing gets + Python. "The dogfood is Rust-default" therefore has to be written down at + every dogfood call site, and this is what reads it back. + """ + check = "internal-default-not-rust" + problems = [] + d_sites = sites_of_class("D") + if not d_sites: + problems.append("no Class-D call site exists at all — the Rust-default population is " + "empty, so the Stage-2 claim has nothing to stand on") + for (wf, job) in sorted(d_sites): + path = ROOT / wf + span = next((s for s in job_spans(path) if s[0] == job), None) + if span is None: + problems.append(f"{job}: not found in {wf}") + continue + text = job_code(path, span[1], span[2]) + if not any(re.search(p, text) for p in _RUST_SELECTORS): + problems.append(f"{wf}::{job} is Class D but selects no engine explicitly — it " + "would run the PUBLIC default, which is Python") + if "OWEN_RUST_CORE" not in text: + problems.append(f"{wf}::{job} selects Rust without supplying a candidate through " + "OWEN_RUST_CORE") + if problems: + fail(check, "; ".join(problems)) + else: + ok(check, f"all {len(d_sites)} Class-D call sites select Rust explicitly and supply " + "the candidate through OWEN_RUST_CORE") + + +def control_wrong_rust_candidate() -> None: + """Class-D runs the PRODUCTION own-cli, and the job says which binary ran. + + The three wrong candidates are all things this repository actually builds: + `own-shadow-engine` (the #260 dev adapter), the Stage-1 test stub, and the + `fault-injection` build. A dogfood job that quietly ran any of them would be + green and meaningless. + """ + check = "wrong-rust-candidate" + banned = { + "own-shadow-engine": "the #260 dev-only compare adapter", + "stage1-stub": "the Stage-1 test instrument", + "OWEN_STAGE1_STUB": "the Stage-1 test instrument", + "target-fault": "the fault-injection build", + } + problems = [] + for (wf, job) in sorted(sites_of_class("D")): + path = ROOT / wf + span = next((s for s in job_spans(path) if s[0] == job), None) + if span is None: + continue + text = job_code(path, span[1], span[2]) + for token, what in banned.items(): + if re.search(rf"OWEN_RUST_CORE[^\n]*{re.escape(token)}", text): + problems.append(f"{wf}::{job} points OWEN_RUST_CORE at {what} ({token})") + if not re.search(r"cargo build[^\n]*-p own-cli[^\n]*--release", text): + problems.append(f"{wf}::{job} does not build the production own-cli deterministically") + if not re.search(r"sha256sum|Get-FileHash|shasum", text): + problems.append(f"{wf}::{job} never records the candidate's identity — 'a Rust " + "binary ran' is not the same claim as 'THIS binary ran'") + if problems: + fail(check, "; ".join(problems)) + else: + ok(check, "every Class-D job builds the production own-cli and records its identity") + + +def control_locator_contract_bypassed() -> None: + """D3 has one locator and no discovery. A dogfood job is not an exception. + + Discovery is how a stale binary silently stands in for the one under test — + the failure this repository has already paid for once. `which own-cli` in a + CI job is the same defect as PATH lookup in the launcher. + """ + check = "locator-contract-bypassed" + discovery = [ + (r"which own-cli", "PATH lookup"), + (r"command -v own-cli", "PATH lookup"), + (r"Get-Command own-cli", "PATH lookup"), + (r"find [^\n]*-name ['\"]?own-cli", "filesystem probing"), + (r"ls [^\n]*target[^\n]*own-cli\*", "target-dir globbing"), + ] + problems = [] + for (wf, job) in sorted(sites_of_class("D")): + path = ROOT / wf + span = next((s for s in job_spans(path) if s[0] == job), None) + if span is None: + continue + text = job_code(path, span[1], span[2]) + for pat, what in discovery: + if re.search(pat, text): + problems.append(f"{wf}::{job} does {what} for its candidate instead of naming " + "it through OWEN_RUST_CORE") + if problems: + fail(check, "; ".join(problems)) + else: + ok(check, "no Class-D job discovers its candidate; the locator is OWEN_RUST_CORE alone") + + +def control_platform_leg_lost() -> None: + """The Rust-default claim covers Linux AND Windows. + + The two launcher surfaces differ in exactly the mechanics that broke during + Stage 1 — process launch, executable bits, path forms, stream capture — so + a Linux-only dogfood claim is a claim about half the product. + + It reads each Class-D job's actual runner images (see `runner_oses`), which + is the whole point: the `windows-latest` that appears in a step guard is + not a Windows leg. + """ + check = "platform-leg-lost" + seen: dict[str, list[str]] = {"linux": [], "windows": []} + problems = [] + for (wf, job) in sorted(sites_of_class("D")): + path = ROOT / wf + span = next((s for s in job_spans(path) if s[0] == job), None) + if span is None: + continue + images = runner_oses(path, span[1], span[2]) + if not images: + problems.append(f"{wf}::{job}: could not read which runner it uses at all") + for image in images: + fam = platform_of(image) + if fam in seen: + seen[fam].append(f"{job} ({image})") + missing = [p for p, jobs in seen.items() if not jobs] + if missing: + problems.append(f"the Rust-default dogfood has no {', '.join(missing)} leg — " + f"present: { {k: v for k, v in seen.items() if v} }") + if problems: + fail(check, "; ".join(problems)) + else: + ok(check, f"Class-D dogfood runs on both platforms (linux: {', '.join(seen['linux'])}; " + f"windows: {', '.join(seen['windows'])})") + + +def control_compare_gate_dropped() -> None: + """Rust exposure is never bought with differential evidence. + + The cheapest way to make a Rust-default dogfood green is to delete the job + that would have disagreed with it. + """ + check = "compare-gate-dropped" + c_sites = sites_of_class("C") + problems = [] + if len(c_sites) < 5: + problems.append(f"only {len(c_sites)} compare gates remain; the ratified #260 set is " + "five (two in CI, three in the sweep)") + found, _ = enumerate_call_sites() + for key in sorted(c_sites): + if key not in found: + problems.append(f"compare gate {key[0]}::{key[1]} no longer invokes anything") + for (wf, job) in sorted(c_sites): + path = ROOT / wf + span = next((s for s in job_spans(path) if s[0] == job), None) + if span is None: + problems.append(f"compare gate {job} is gone from {wf}") + continue + text = job_code(path, span[1], span[2]) + if "if: false" in text.replace(" ", " "): + problems.append(f"{wf}::{job} is disabled") + if problems: + fail(check, "; ".join(problems)) + else: + ok(check, f"all {len(c_sites)} compare gates are present and enabled") + + +def control_python_reference_lost() -> None: + """The explicit reference path still exists, and still runs. + + Compare needs a reference. If the Python path decays into something nobody + executes, the differential evidence decays with it and Rust-default dogfood + starts passing because nothing is left to disagree with it. + """ + check = "python-reference-lost" + problems = [] + b_sites = sites_of_class("B") + if not b_sites: + problems.append("no Class-B explicit-reference call site remains") + found, _ = enumerate_call_sites() + for key in sorted(b_sites): + if key not in found: + problems.append(f"reference call site {key[0]}::{key[1]} no longer invokes anything") + + # And the runtime half: `--engine python` is not merely spelled somewhere, + # it produces a verdict. + if not have_dotnet(): + skip(check, "no dotnet, so the reference path could not be executed") + return + with tempfile.TemporaryDirectory(prefix="owen-stage2-ref-") as td: + sample = Path(td) / "sample" + sample.mkdir() + (sample / "Leak.cs").write_text(SAMPLE_CS, encoding="utf-8") + r = subprocess.run( + [bash_exe(), str(ROOT / "scripts/own-check.sh"), + "--engine", "python", "--format", "human", "--", str(sample)], + capture_output=True, cwd=str(ROOT), check=False) + if b"OWN001" not in r.stdout: + problems.append(f"--engine python produced no verdict (exit {r.returncode}) " + f"[{tail(r)}]") + if problems: + fail(check, "; ".join(problems)) + else: + ok(check, f"{len(b_sites)} reference call sites remain and --engine python still " + "produces a verdict") + + +# --- 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. + """ + check = "public-default-moved" + problems = [] + + # The two written-down defaults. + action = (ROOT / "action.yml").read_text(encoding="utf-8") + 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") + + 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") + + # 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. + if not re.search(r'--engine "\$OWN_ENGINE"', action): + problems.append("action.yml no longer forwards its engine input verbatim to " + "own-check.sh — the public default could be overridden in transit") + + 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: + 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: + 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)}]") + + 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") + + +def control_rust_job_falls_back() -> None: + """A forced Rust failure fails visibly; Python never rescues it. + + Two halves. Statically, a Class-D step must not swallow the launcher's exit + code — `|| true` on the dogfood run turns every Stage-2 control into + decoration. At run time, an explicitly Rust-selected run whose candidate + cannot work must not produce a verdict. + """ + check = "rust-job-falls-back" + problems = [] + for (wf, job) in sorted(sites_of_class("D")): + path = ROOT / wf + span = next((s for s in job_spans(path) if s[0] == job), None) + if span is None: + continue + for n, line in enumerate(job_code(path, span[1], span[2]).splitlines(), span[1]): + if _strip_comment(line) and re.search(r"own-check\.(sh|ps1)[^\n]*\|\|\s*true", line): + problems.append(f"{wf}:{n}: the dogfood run swallows its own exit code") + if _strip_comment(line) and re.search(r"continue-on-error:\s*true", line): + problems.append(f"{wf}:{n}: a Class-D step is allowed to fail silently") + + if not have_dotnet(): + skip(check, "no dotnet, so the forced-failure run could not be made") + return + with tempfile.TemporaryDirectory(prefix="owen-stage2-nofb-") as td: + sample = Path(td) / "sample" + sample.mkdir() + (sample / "Leak.cs").write_text(SAMPLE_CS, encoding="utf-8") + broken = Path(td) / "not-a-core" + broken.write_text("this is not an executable image\n", encoding="utf-8") + env = dict(os.environ) + env["OWEN_RUST_CORE"] = str(broken) + r = subprocess.run( + [bash_exe(), str(ROOT / "scripts/own-check.sh"), + "--engine", "rust", "--format", "human", "--", str(sample)], + capture_output=True, env=env, cwd=str(ROOT), check=False) + if b"OWN001" in r.stdout: + problems.append("an explicitly Rust-selected run produced a verdict with an " + "unusable candidate — Python answered for Rust") + elif r.returncode == 0: + problems.append(f"an unusable Rust candidate exited 0 [{tail(r)}]") + + if problems: + fail(check, "; ".join(problems)) + else: + ok(check, "no Class-D step swallows its exit code, and a forced Rust failure produces " + "no verdict") + + +def run() -> int: + control_census() + control_internal_default_not_rust() + control_public_default_moved() + control_rust_job_falls_back() + control_wrong_rust_candidate() + control_locator_contract_bypassed() + control_python_reference_lost() + control_compare_gate_dropped() + control_platform_leg_lost() + + print() + print(f"stage-2 dogfood controls: {len(_PASSES)} passed, {len(_FAILURES)} failed, " + f"{len(_SKIPS)} skipped") + if _SKIPS: + print(" skipped (set OWEN_STAGE2_REQUIRE=1 to make these failures):") + for name, why in _SKIPS: + print(f" {name}: {why}") + return 1 if _FAILURES else 0 + + +if __name__ == "__main__": + sys.exit(run())