Skip to content

Commit d471253

Browse files
committed
feat(own-cli): the production Rust OwnIR executable (#261 H.2)
`own-cli ownir <facts> [--format F] [--severity S] [--verbosity V]` — the one core invocation the product seam makes today, as a native binary behind the unchanged launcher. It reproduces the reference byte for byte over all 80 frozen cases: argument handling, the display policy, the stream split, the four formats and every exit code. The two halves have different oracles, and the boundary is the surface (C-1). The top-level shell follows the public `owen` convention — a parity surface of its own, with no Python byte oracle, written once and shared with the fixture manifest. Everything after `ownir` is the reference as measured, including the parts that surprise: a positional-count error prints the whole module docstring to STDOUT with exit 2, and an unknown flag is a positional rather than an error. `ownir --help` is the one case declared a defect and not ported. What it reuses and what it owns: * `own_bridge::check_facts` is the analysis and `render_finding` / `build_sarif` are the renders — already byte-pinned by BR-V9, never re-derived here; * the CLI owns the display policy, because it is CLI logic. It is a pure function of the findings and the three options, so the traps are unit tests rather than process invocations: the exit is `1 if leaks else 0` and no option moves it, `quiet` hides advisories and nothing else, the `ok` line fires on `not shown` so an all-suppressed document prints ok AND a tally, and the verbose breakdown counts every finding; * the CLI owns the SARIF SERIALIZATION, because `cmd_ownir` writes the same document `json.dumps(indent=2)` does — pure ASCII with `\uXXXX` escapes and a surrogate pair above the BMP — while the BR-V9 goldens are `ensure_ascii=False`. Two byte shapes, one builder; the builder is untouched. The process contract, under `panic = "unwind"`: a hook suppresses the default panic output and records the payload, and a top-level `catch_unwind` turns the unwind into one actionable stderr diagnostic and exit 70, never 101. The hook alone would only observe the panic. Under OWNLANG_DEBUG the payload and a captured backtrace print and the exit is still 70 — the asymmetry `run()` keeps, because exit 1 reads as findings. An off-by-default `fault-injection` feature gates the two dev-only hooks that force each failure mode, so both are measured rather than asserted. The DAG gains exactly two edges, `own-cli -> own-ir` and `own-cli -> own-bridge`, registered in `dag.rs` where an unregistered member fails the whole `cargo test`. No `own-codegen`, no `own-shadow`, no `sha2`. The `panic`-per-binary note above `[profile.release]` is corrected in place as a design note: Cargo cannot set `panic` per package — a profile applies to every target of a build — so `abort for own-cli` was never a plan Cargo could execute. Nothing about the built artifacts moves. Refs #261, #345, #262, #250 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DTGMj7edRtjhyeXzkeiSWv
1 parent 2900f60 commit d471253

10 files changed

Lines changed: 1365 additions & 3 deletions

File tree

‎rust/Cargo.lock‎

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎rust/Cargo.toml‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
[workspace]
1010
resolver = "2"
11-
members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis", "crates/own-lowered", "crates/own-bridge", "crates/own-shadow"]
11+
members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis", "crates/own-lowered", "crates/own-bridge", "crates/own-shadow", "crates/own-cli"]
1212

1313
[workspace.package]
1414
edition = "2021"
@@ -51,8 +51,20 @@ panic = "deny"
5151
dbg_macro = "deny"
5252
print_stdout = "deny"
5353

54-
# Release profile per P-022 — panic is per-binary, NOT set here: "abort" for
55-
# own-cli, "unwind" for any LSP binary (salsa cancels via unwinding).
54+
# Release profile per P-022. `panic` is deliberately NOT set here, and the note
55+
# that used to stand in this place — "panic is per-binary: abort for own-cli,
56+
# unwind for any LSP binary" — was a stale design note rather than a plan Cargo
57+
# can execute: a profile applies to EVERY target of a build, so there is no
58+
# per-package `panic` and a `panic = "abort"` written here would abort the whole
59+
# workspace, an LSP binary included.
60+
#
61+
# The workspace therefore keeps the default `unwind`, and #261 rules that this
62+
# is the contract build for `own-cli`: a custom panic hook suppresses the
63+
# default output and a top-level `catch_unwind` turns an ordinary panic into one
64+
# actionable stderr diagnostic and exit 70 (never 101). A hook alone would only
65+
# OBSERVE the panic; the catch is what produces the exit code, and it needs
66+
# unwinding to exist. Corrected here and in its P-022 mirror as a design note,
67+
# not as a behaviour change: nothing about the built artifacts moves.
5668
[profile.release]
5769
lto = "thin"
5870
codegen-units = 1

‎rust/crates/own-cli/Cargo.toml‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# The production OwnIR executable (P-022 step 7b, #261). P-022's crate plan
2+
# names `own-cli` the entry point, and this is it, created with the single
3+
# `ownir` subcommand — the one core invocation the product seam makes today
4+
# (`own-check.sh`, `own-check.ps1`, the Action and `owen check` all run
5+
# `python -m ownlang ownir <facts> --format F --severity S`, and nothing else).
6+
# #345 adds `cfg`/`summaries`/`explain`/`.own check`/`emit` to THIS binary
7+
# later, so the dispatch is one table and the help is one surface rather than a
8+
# special case those commands would have to rewrite.
9+
#
10+
# The DAG (own-diagnostics/tests/dag.rs): `own-ir` for the typed strict door and
11+
# `own-bridge` for the analysis and the renders. Nothing else — the CLI holds no
12+
# analysis and no bridge logic, only argument handling, I/O, the display policy
13+
# and the process contract. `own-bridge` may be depended on ONLY by entry-point
14+
# crates, and this is the one P-022 named.
15+
[package]
16+
name = "own-cli"
17+
version = "0.1.0"
18+
edition.workspace = true
19+
rust-version.workspace = true
20+
license.workspace = true
21+
publish.workspace = true
22+
description = "own-cli: the production OwnIR executable (`own-cli ownir`), parity with `python -m ownlang ownir`"
23+
24+
[[bin]]
25+
name = "own-cli"
26+
path = "src/main.rs"
27+
28+
[dependencies]
29+
own-ir = { path = "../own-ir" }
30+
own-bridge = { path = "../own-bridge" }
31+
# The SARIF log is serialized here rather than in the bridge because the CLI's
32+
# byte shape is NOT the fixture emitter's: `cmd_ownir` prints
33+
# `json.dumps(indent=2)` with `ensure_ascii` left at its default, so the CLI
34+
# path is pure ASCII with `\uXXXX` escapes, while the BR-V9 goldens are
35+
# `ensure_ascii=False`. Two byte shapes, one builder — see src/sarif.rs.
36+
serde_json.workspace = true
37+
# Unicode general categories, the data behind CPython's `str.isprintable()`.
38+
# The reference interpolates `repr()` of a rejected flag value
39+
# (`unknown --format {fmt!r}`), so byte-identical usage errors need CPython's
40+
# quoting rules. Not a workspace-crate edge: `own-syntax` owns the same helper
41+
# but the DAG does not admit `own-cli -> own-syntax` (see src/pyrepr.rs).
42+
unicode-properties.workspace = true
43+
44+
[features]
45+
# OFF by default and absent from every production build. It gates two dev-only
46+
# hooks that force the two failure modes #261 rules on, so the forced-panic and
47+
# forced-death controls are MEASURED by the integration test rather than
48+
# asserted from the design. See src/faults.rs.
49+
fault-injection = []
50+
51+
[lints]
52+
workspace = true

‎rust/crates/own-cli/src/faults.rs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//! The two forced-failure controls, behind the off-by-default
2+
//! `fault-injection` cargo feature.
3+
//!
4+
//! #261 rules on two failure modes:
5+
//!
6+
//! ```text
7+
//! catchable panic -> exactly one actionable stderr diagnostic + exit 70,
8+
//! never 101
9+
//! uncatchable death -> a visible hard failure: non-zero, outside
10+
//! {0, 1, 2, 70}, no findings, no `ok` line, never
11+
//! masked. NO particular OS exit number is contracted.
12+
//! ```
13+
//!
14+
//! Both are *measured* rather than asserted from the design, which needs a way
15+
//! to make them happen on demand — and a production binary must not carry one.
16+
//! Hence the feature: `cargo build -p own-cli` compiles [`maybe_inject`] to a
17+
//! `const fn` with an empty body, and only
18+
//! `cargo test -p own-cli --features fault-injection` builds the hooks at all.
19+
//!
20+
//! The panic is raised with [`std::panic::panic_any`] rather than the `panic!`
21+
//! macro. That is not a way around the workspace's `clippy::panic` deny — it is
22+
//! the right API for this job: `panic_any` takes the payload as a value, which
23+
//! is exactly what the diagnostic has to recover and print, so the control
24+
//! exercises the payload path a real panic would take instead of a stringly
25+
//! special case.
26+
27+
/// Force one of the two failure modes when the environment asks for it.
28+
///
29+
/// Called after argument parsing, so the control travels the same path a real
30+
/// panic in the analysis would: through `catch_unwind` in `main`, with the hook
31+
/// already installed.
32+
#[cfg(feature = "fault-injection")]
33+
pub(crate) fn maybe_inject() {
34+
if std::env::var_os("OWN_CLI_FAULT_PANIC").is_some() {
35+
std::panic::panic_any("forced panic (fault-injection)");
36+
}
37+
if std::env::var_os("OWN_CLI_FAULT_ABORT").is_some() {
38+
// A genuinely UNCATCHABLE termination: `catch_unwind` cannot see it and
39+
// no hook runs. What the OS then reports (a SIGABRT on Unix, an abort
40+
// status on Windows) is recorded by the note, never contracted.
41+
std::process::abort();
42+
}
43+
}
44+
45+
/// The production build: nothing to inject, and nothing compiled in to inject
46+
/// it with.
47+
#[cfg(not(feature = "fault-injection"))]
48+
pub(crate) const fn maybe_inject() {}

0 commit comments

Comments
 (0)