Skip to content

Commit 2188eb9

Browse files
committed
ci(own-cli): replay the CLI contract on Linux and Windows (#261 H.4)
`own-cli (ownir parity replay)` builds the binary and replays every frozen case on both platforms with zero Python, then runs the two failure-mode controls behind the `fault-injection` feature — which no production build carries, so they need their own invocation. Windows is in the matrix because the fixture is authored on Linux, and a byte contract that has only ever been replayed on its authoring platform has not been tested: path forms, line endings and the OS error text are exactly where a CLI port diverges. `rust-core` is deliberately NOT widened to Windows for every crate — that is a separate cost decision, and this is the crate whose contract is platform-shaped. The Python half needs no new job: `tests/run_tests.py` discovers `test_cli_ownir_fixtures.py` like every other `test_*.py`, so the existing matrix re-verifies each python-oracle case against the reference on 3.11/3.12/3.13. Also lands a TEMPORARY workflow and script that measure the reference on WINDOWS — non-ASCII output through a pipe, closed stdout, and interruption. #261 rules that SIGINT is measured on both platforms before anything is written down, and this implementing environment is Linux-only; a measurement nobody took is not a measurement. It runs the reference rather than the port, asserts nothing, and gates nothing. Both files are deleted once the note carries the numbers. Refs #261, #262, #250 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTGMj7edRtjhyeXzkeiSWv
1 parent 9fa54a7 commit 2188eb9

3 files changed

Lines changed: 264 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,42 @@ jobs:
7272
- name: cargo test
7373
run: cargo test
7474

75+
# P-022 step 7b (#261) — the production OwnIR executable's parity replay, on
76+
# BOTH platforms. The Python half of this contract is gated by the `tests`
77+
# matrix above (`tests/test_cli_ownir_fixtures.py` is auto-discovered like
78+
# every other `test_*.py`, and re-verifies each `oracle: "python"` case
79+
# against the reference on 3.11/3.12/3.13). This job is the other half and
80+
# runs NO Python: it builds the binary and replays the frozen bytes.
81+
#
82+
# Windows is here because the fixture is authored on Linux and a byte
83+
# contract that has only ever been replayed on its authoring platform has not
84+
# been tested — path forms, line endings and the OS error text are exactly
85+
# where a CLI port diverges. `rust-core` is deliberately NOT widened to
86+
# Windows for every crate: that is a separate cost decision, and this is the
87+
# crate whose contract is platform-shaped.
88+
own-cli-parity:
89+
name: own-cli (ownir parity replay)
90+
strategy:
91+
fail-fast: false
92+
matrix:
93+
os: [ubuntu-latest, windows-latest]
94+
runs-on: ${{ matrix.os }}
95+
defaults:
96+
run:
97+
working-directory: rust
98+
steps:
99+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
100+
- uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10
101+
with:
102+
toolchain: stable
103+
- name: cargo test -p own-cli (the frozen CLI contract, zero Python)
104+
run: cargo test -p own-cli
105+
# The forced-panic and forced-death controls are OFF in every production
106+
# build, so they need their own invocation. They are what makes #261's
107+
# panic ruling a measurement rather than a design claim.
108+
- name: cargo test -p own-cli --features fault-injection (the failure-mode controls)
109+
run: cargo test -p own-cli --features fault-injection --test faults
110+
75111
# P-022 step 7a (#260) — COMPARE MODE over the committed corpus: the FAST half
76112
# of #260's test matrix, and one leg of it. The five pinned OSS repositories,
77113
# the large-solution controls and the examples tree are the scheduled/manual
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# TEMPORARY — #261 H.0, the Windows half of the reference measurement.
2+
#
3+
# This workflow exists to answer three questions the implementing environment
4+
# (Linux-only) could not: what does `python -m ownlang ownir` do on WINDOWS
5+
# when it prints non-ASCII through a pipe, when its stdout is closed, and when
6+
# it is interrupted. #261's SIGINT ruling is "measure the Windows and Linux
7+
# reference first and record it; do not invent 130 as a universal contract",
8+
# and a measurement nobody took is not a measurement.
9+
#
10+
# It runs the REFERENCE, not the port, and it asserts nothing: every step
11+
# prints what it saw and exits 0. Nothing gates on it.
12+
#
13+
# **It is deleted once its run is recorded in docs/notes/p022-cli-ownir.md.**
14+
# If you are reading this on a merged commit, that deletion was missed — the
15+
# file has no reason to outlive the note it fed.
16+
name: CLI reference measurement (TEMPORARY, #261 H.0)
17+
18+
on:
19+
push:
20+
branches:
21+
- claude/rust-ownir-executable-6h1zrp
22+
23+
permissions:
24+
contents: read
25+
26+
jobs:
27+
windows-reference:
28+
name: python -m ownlang ownir, measured on Windows
29+
runs-on: windows-latest
30+
steps:
31+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
32+
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
33+
with:
34+
python-version: "3.11"
35+
36+
- name: Non-ASCII output, and the OS error text
37+
shell: bash
38+
run: python scripts/measure_cli_reference.py streams
39+
40+
- name: Closed stdout
41+
shell: bash
42+
run: python scripts/measure_cli_reference.py closed-stdout
43+
44+
- name: Interruption (CTRL_BREAK to a new process group)
45+
shell: bash
46+
run: python scripts/measure_cli_reference.py interrupt

‎scripts/measure_cli_reference.py‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
#!/usr/bin/env python3
2+
"""TEMPORARY — #261 H.0: measure `python -m ownlang ownir` on this platform.
3+
4+
The implementing environment for #261 is Linux-only, and three of the
5+
reference's behaviours are platform-shaped:
6+
7+
* **non-ASCII output**, because the reference `print`s through the console or
8+
pipe encoding, so a Windows run may emit different bytes than a Linux one —
9+
or fail outright with a `UnicodeEncodeError`, which would land on the exit-70
10+
catch-all;
11+
* **closed stdout**, which on Linux is a deterministic `BrokenPipeError` and
12+
exit 70;
13+
* **interruption**, which #261 rules must be *measured* on both platforms
14+
before anything is written down, and where `130` is explicitly not to be
15+
invented as a universal contract.
16+
17+
This script asserts nothing and gates nothing. It prints what it saw and exits
18+
0, so the run's log is the record. It is deleted together with
19+
`.github/workflows/cli-reference-measurement.yml` once
20+
`docs/notes/p022-cli-ownir.md` carries the numbers.
21+
22+
Usage: python scripts/measure_cli_reference.py {streams|closed-stdout|interrupt}
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import json
28+
import os
29+
import platform
30+
import subprocess
31+
import sys
32+
import tempfile
33+
import time
34+
35+
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
36+
_FIXTURES = os.path.join(_ROOT, "tests", "fixtures")
37+
# The synthesized large document lives in the system temp dir, never in the
38+
# tree: it is an input to a measurement, not an artifact of one.
39+
_SCRATCH = os.path.join(tempfile.gettempdir(), "cli-reference-measurement")
40+
41+
42+
def _env() -> dict[str, str]:
43+
env = {k: v for k, v in os.environ.items() if k != "OWNLANG_DEBUG"}
44+
env["PYTHONPATH"] = _ROOT + os.pathsep + env.get("PYTHONPATH", "")
45+
return env
46+
47+
48+
def _banner() -> None:
49+
print(f"platform : {platform.platform()}")
50+
print(f"python : {sys.version.split()[0]}")
51+
print(f"sys.stdout.encoding : {sys.stdout.encoding}")
52+
print(f"filesystem encoding : {sys.getfilesystemencoding()}")
53+
print("-" * 72)
54+
55+
56+
def _run(argv: list[str], cwd: str) -> tuple[int, bytes, bytes]:
57+
proc = subprocess.run(
58+
[sys.executable, "-m", "ownlang", "ownir", *argv],
59+
capture_output=True, check=False, env=_env(), cwd=cwd)
60+
return proc.returncode, proc.stdout, proc.stderr
61+
62+
63+
def _show(label: str, code: int, out: bytes, err: bytes) -> None:
64+
print(f"### {label}")
65+
print(f" exit : {code}")
66+
print(f" stdout : {out!r}")
67+
print(f" stderr : {err!r}")
68+
print(f" stdout is pure ASCII: {all(b < 128 for b in out)}")
69+
print()
70+
71+
72+
def streams() -> int:
73+
"""Every case whose bytes could depend on the console encoding, plus the
74+
two whose text is the platform's own."""
75+
cli = os.path.join(_FIXTURES, "cli_ownir")
76+
cases: list[tuple[str, list[str]]] = [
77+
("non-ASCII `file`, human", ["inputs/nonascii_file.facts.json"]),
78+
("non-ASCII `file`, github",
79+
["inputs/nonascii_file.facts.json", "--format", "github"]),
80+
("non-ASCII `file`, sarif",
81+
["inputs/nonascii_file.facts.json", "--format", "sarif"]),
82+
("non-ASCII + space in the PATH", ["inputs/pa th ünïcødé/facts.json"]),
83+
("the em dash in the ok line", ["../verdict_renders/render_empty.facts.json"]),
84+
("a missing file (OS error text)", ["inputs/no_such_facts.json"]),
85+
("a directory (OS error text)", ["inputs"]),
86+
]
87+
for label, argv in cases:
88+
_show(label, *_run(argv, cli))
89+
return 0
90+
91+
92+
def closed_stdout() -> int:
93+
"""What the reference does when the consumer goes away. On Linux this is a
94+
deterministic `BrokenPipeError` and exit 70."""
95+
cli = os.path.join(_FIXTURES, "cli_ownir")
96+
big = os.path.join(_SCRATCH, "big.facts.json")
97+
os.makedirs(_SCRATCH, exist_ok=True)
98+
document = {"ownir_version": 0, "module": "Big", "components": [
99+
{"name": f"Vm{c}", "file": f"Vm{c}.cs", "subscriptions": [
100+
{"event": f"Bus{c}.E{s}", "handler": f"On{s}", "line": s + 1,
101+
"source": "static"} for s in range(40)]}
102+
for c in range(400)]}
103+
with open(big, "w", encoding="utf-8") as handle:
104+
json.dump(document, handle)
105+
106+
for trial in range(3):
107+
producer = subprocess.Popen(
108+
[sys.executable, "-m", "ownlang", "ownir", big, "--format", "sarif"],
109+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=_env(), cwd=cli)
110+
# Read one byte and close: the producer is still writing megabytes.
111+
assert producer.stdout is not None
112+
producer.stdout.read(1)
113+
producer.stdout.close()
114+
_, err = producer.communicate()
115+
print(f"### closed stdout, trial {trial}")
116+
print(f" exit : {producer.returncode}")
117+
print(f" stderr : {err[:400]!r}")
118+
print()
119+
return 0
120+
121+
122+
def interrupt() -> int:
123+
"""Interruption, measured rather than assumed.
124+
125+
On Windows a console Ctrl+C cannot be aimed at one child, so the child is
126+
started in its own process group and sent `CTRL_BREAK_EVENT` — which
127+
CPython raises as `KeyboardInterrupt` exactly as Ctrl+C does. On Unix the
128+
equivalent is `SIGINT` to the child's own session.
129+
"""
130+
import signal
131+
cli = os.path.join(_FIXTURES, "cli_ownir")
132+
big = os.path.join(_SCRATCH, "big.facts.json")
133+
if not os.path.exists(big):
134+
closed_stdout()
135+
136+
windows = os.name == "nt"
137+
for trial in range(3):
138+
kwargs: dict[str, object] = {}
139+
if windows:
140+
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
141+
else:
142+
kwargs["start_new_session"] = True
143+
child = subprocess.Popen(
144+
[sys.executable, "-m", "ownlang", "ownir", big, "--format", "sarif"],
145+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=_env(), cwd=cli,
146+
**kwargs) # type: ignore[arg-type]
147+
time.sleep(0.6)
148+
try:
149+
if windows:
150+
os.kill(child.pid, signal.CTRL_BREAK_EVENT)
151+
else:
152+
os.killpg(os.getpgid(child.pid), signal.SIGINT)
153+
except OSError as exc:
154+
print(f"### interrupt trial {trial}: could not signal: {exc}")
155+
child.kill()
156+
child.communicate()
157+
continue
158+
out, err = child.communicate()
159+
text = err.decode("utf-8", "replace")
160+
print(f"### interrupt trial {trial}")
161+
print(f" returncode : {child.returncode}")
162+
print(f" stdout : {len(out)} bytes")
163+
print(f" stderr tail: {text[-300:]!r}")
164+
print()
165+
return 0
166+
167+
168+
_MODES = {"streams": streams, "closed-stdout": closed_stdout,
169+
"interrupt": interrupt}
170+
171+
172+
def main(argv: list[str]) -> int:
173+
if len(argv) != 1 or argv[0] not in _MODES:
174+
print(f"usage: measure_cli_reference.py {{{'|'.join(_MODES)}}}",
175+
file=sys.stderr)
176+
return 2
177+
_banner()
178+
return _MODES[argv[0]]()
179+
180+
181+
if __name__ == "__main__":
182+
raise SystemExit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)