diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70c0357..ede8849 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,25 @@ jobs: - name: Test run: npm test + # Boundary-oracle mutation ratchet: a totality oracle only protects the system + # if it actually CATCHES a break at its chokepoint. This breaks each boundary's + # chokepoint with a compile-safe mutation and requires its oracle to fail + # (ANCHORED); an oracle that stays green under a broken floor (VACUOUS) — the + # rot that let the trust-split oracle pass a literal inversion before #14 + # hardened it — fails the gate. PR runs only edges whose chokepoint/oracle + # changed (incremental); push to main runs the full backstop. (Full-mode in CI + # verified on this branch's diagnostic run.) python3 is preinstalled on + # ubuntu-latest; the harness reverts every mutation via git. + - name: Boundary oracle mutation ratchet + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" + python3 scripts/invariant-mutation-probe.py --changed FETCH_HEAD + else + python3 scripts/invariant-mutation-probe.py + fi + # Ratchets: no new conventions, no new raw SQL interpolation sites, and the # trust atlas stays in sync with the boundary claims (no managed chokepoint # left off the atlas, no dangling edge). diff --git a/mnemion-js/scripts/invariant-mutation-probe.py b/mnemion-js/scripts/invariant-mutation-probe.py index fde4821..6e86c11 100755 --- a/mnemion-js/scripts/invariant-mutation-probe.py +++ b/mnemion-js/scripts/invariant-mutation-probe.py @@ -121,7 +121,7 @@ def restore(): for f in {e[0] for e in RUN}: sh(["git","checkout","--",f]) -results=[]; notes=[] +results=[]; notes=[]; diaglogs=[] try: print(f"{'BOUNDARY (oracle)':<48} VERDICT") print(f"{'-'*48} -------") @@ -131,26 +131,44 @@ def restore(): if match not in src: print(f"{label:<48} SKIP (match drifted)"); results.append((label,"SKIP")); continue open(path,"w").write(src.replace(match,repl,1)) - r=sh(["npx","vitest","run",oracle]) + # NO_COLOR: keep vitest output plain. CI forces ANSI, which interleaves + # escape codes INTO the summary ("Tests \x1b[..m1 failed") and broke the + # ANCHORED match on the first CI run — the tests DID catch the mutation, the + # harness just couldn't read its own output. Belt: strip ANSI before matching. + r=sh(["npx","vitest","run",oracle], env={**os.environ,"NO_COLOR":"1","FORCE_COLOR":"0"}) sh(["git","checkout","--",f]) # revert immediately - log=r.stdout+r.stderr + log=re.sub(r"\x1b\[[0-9;]*m","",r.stdout+r.stderr) + # ANCHORED = the oracle RAN and an assertion FAILED (any of vitest's failure + # markers), as opposed to erroring before running (INVALID). + ran_and_failed = (re.search(r"Tests\s+\d+\s+failed", log) + or re.search(r"Failed Tests\s+\d+", log) + or re.search(r"\bFAIL\b.*\.test\.", log)) if r.returncode==0: if shadow: v="SHADOWED"; mark="o SHADOWED (expected — chokepoint not operative in test runtime)" else: v="VACUOUS"; mark="x VACUOUS (oracle missed the break — CONFIRM not shadowed)" - elif re.search(r"Tests\s+\d+\s+failed", log): + elif ran_and_failed: v="ANCHORED"; mark="+ ANCHORED" else: v="INVALID"; mark="~ INVALID (no compile / inconclusive)" print(f"{label:<48} {mark}"); results.append((label,v)) if v=="SHADOWED" and shadow: notes.append((label,shadow)) + # Diagnosability: dump the captured oracle output for any verdict that means + # "the harness could not confirm this oracle is semantic" — without it an + # INVALID/VACUOUS in CI is a black box (the lesson from the first re-land). + if v in ("VACUOUS","INVALID"): + tail="\n".join((log or "").splitlines()[-40:]) + diaglogs.append((label,v,tail)) finally: restore() for label,note in notes: print(f"\n note [{label}]:\n {note}") +for label,v,tail in diaglogs: + print(f"\n----- captured oracle output [{label}] ({v}) — last 40 lines -----\n{tail}") + a=sum(1 for _,v in results if v=="ANCHORED"); vc=sum(1 for _,v in results if v=="VACUOUS") iv=sum(1 for _,v in results if v=="INVALID"); sh_=sum(1 for _,v in results if v=="SHADOWED") sk=sum(1 for _,v in results if v=="SKIP")