diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4010829e..ba9347a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,42 @@ jobs: - name: cargo test run: cargo test + # P-022 step 7b (#261) — the production OwnIR executable's parity replay, on + # BOTH platforms. The Python half of this contract is gated by the `tests` + # matrix above (`tests/test_cli_ownir_fixtures.py` is auto-discovered like + # every other `test_*.py`, and re-verifies each `oracle: "python"` case + # against the reference on 3.11/3.12/3.13). This job is the other half and + # runs NO Python: it builds the binary and replays the frozen bytes. + # + # Windows is here 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. + own-cli-parity: + name: own-cli (ownir parity replay) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + - name: cargo test -p own-cli (the frozen CLI contract, zero Python) + run: cargo test -p own-cli + # The forced-panic and forced-death controls are OFF in every production + # build, so they need their own invocation. They are what makes #261's + # panic ruling a measurement rather than a design claim. + - name: cargo test -p own-cli --features fault-injection (the failure-mode controls) + run: cargo test -p own-cli --features fault-injection --test faults + # 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 diff --git a/docs/evidence/p022-cli-1.json b/docs/evidence/p022-cli-1.json new file mode 100644 index 00000000..c4ca974d --- /dev/null +++ b/docs/evidence/p022-cli-1.json @@ -0,0 +1,352 @@ +{ + "schema": 1, + "comment": "Mutation campaign definition for #261 261.B (P-022 discipline rules 2 and 3). Each entry is a source mutation: a regex that must match `target` exactly once, its replacement, the rule it attacks, and the tests expected to fail. Replay: python scripts/mutate_campaign.py --campaign docs/evidence/p022-cli-1.json --run", + "campaign": "p022-cli-1", + "description": "#261 261.B, the production OwnIR executable `own-cli ownir`: the display policy the reference's cmd_ownir defines (which findings are shown, the summary and `ok` lines, the verbosity variants, the stream split), the CLI's own SARIF serialization conventions (ASCII escaping, and the suppressed findings that ride in the results), the usage-error exit codes, and the process contract (a catchable panic is one diagnostic and exit 70, never 101). Every mutation is a plausible misreading of the reference rather than a syntactic accident: each one would pass a reviewer who had read the docstring instead of the code. The repair pass (#261 R2/R3) adds the strict-door families: the Version messages, which ruling 2a FIXED to byte parity rather than declaring, and the CLI-B1 boundary, whose CLI-owned wrapper is pinned and whose guard must fail onto rc 70 rather than swallow its own structural drift as the declared tail. The second repair pass (#261 R2b/R3b) adds the classes those families' first measurement could not reach: CPython's dict ORDER and its float SPELLING, the arbitrary-precision integer that changes which branch the reference takes, the round-half-to-even tie in the shortest round-trip digits, and the CLI-B1 negative control's own load-bearingness.", + "layers": [ + { + "id": "own-cli", + "cwd": "rust", + "parser": "cargo", + "command": [ + "cargo", + "test", + "-p", + "own-cli", + "--no-fail-fast" + ] + }, + { + "id": "own-cli-faults", + "cwd": "rust", + "parser": "cargo", + "command": [ + "cargo", + "test", + "-p", + "own-cli", + "--features", + "fault-injection", + "--test", + "faults", + "--no-fail-fast" + ] + }, + { + "id": "own-ir", + "cwd": "rust", + "parser": "cargo", + "command": [ + "cargo", + "test", + "-p", + "own-ir", + "--no-fail-fast" + ] + }, + { + "id": "rust-rest", + "cwd": "rust", + "parser": "cargo", + "command": [ + "cargo", + "test", + "--workspace", + "--exclude", + "own-cli", + "--exclude", + "own-ir", + "--no-fail-fast" + ] + } + ], + "layers_comment": "Four layers rather than `workspace`, because the two failure-mode controls live behind the off-by-default `fault-injection` feature and a campaign that cannot run the layer holding a catcher cannot see it catch. `own-ir` joined with #261's repair pass: ruling 2a moved the Version messages into byte parity, so that crate is now a mutable production surface of this contract. `rust-rest` runs every OTHER member so discipline 3's no-fail-fast holds across layers: it is expected to catch nothing, and a catcher appearing there would mean a mutation reached further than its target.", + "control": { + "id": "M00", + "description": "no mutation — the unmutated tree must pass (harness honesty control)" + }, + "mutations": [ + { + "id": "M01", + "rule": "exit-independent-of-severity", + "description": "an advisory-only document exits 1 — the docstring's 'non-zero if any error-level diagnostic' read as the contract, instead of `1 if leaks else 0`", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "let leaks: Vec<&Finding> = active\\.iter\\(\\)\\.copied\\(\\)\\.filter\\(\\|f\\| !f\\.advisory\\)\\.collect\\(\\);", + "replacement": "let leaks: Vec<&Finding> = active.iter().copied().collect();", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::an_advisory_alone_never_fails_the_run", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M02", + "rule": "suppression-is-counted-not-a-verdict", + "description": "a suppressed finding fails the run — `[OwnIgnore]` counted in the exit code instead of only in the tally", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "let exit = u8::from\\(!leaks\\.is_empty\\(\\)\\);", + "replacement": "let exit = u8::from(!leaks.is_empty() || !suppressed.is_empty());", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::a_suppression_is_counted_but_does_not_fail_the_run", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M03", + "rule": "exit-independent-of-severity", + "description": "`--severity warning` flips the exit to 0 — a presentation choice mistaken for a verdict", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "let exit = u8::from\\(!leaks\\.is_empty\\(\\)\\);", + "replacement": "let exit = u8::from(!leaks.is_empty() && severity != \"warning\");", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::exit_is_independent_of_severity_and_verbosity", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M04", + "rule": "sarif-carries-shown-plus-suppressed", + "description": "SARIF drops the suppressed findings — the human stream's rule applied to a machine document, losing what a consumer is meant to count", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "let listed: Vec = shown\\n \\.iter\\(\\)\\n \\.chain\\(suppressed\\.iter\\(\\)\\)\\n \\.map\\(\\|f\\| \\(\\*f\\)\\.clone\\(\\)\\)\\n \\.collect\\(\\);", + "replacement": "let listed: Vec = shown.iter().map(|f| (*f).clone()).collect();", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::sarif_carries_shown_plus_suppressed", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M05", + "rule": "summary-pluralization", + "description": "the summary pluralizes on `> 1` instead of `!= 1`, so a clean run reads '0 finding'", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "let plural = if leak_count == 1 \\{ \"\" \\} else \\{ \"s\" \\};", + "replacement": "let plural = if leak_count > 1 { \"s\" } else { \"\" };", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::the_summary_pluralizes_on_anything_but_one", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M06", + "rule": "quiet-hides-advisory", + "description": "`quiet` stops hiding the advisory notes — the verbosity option silently becomes a no-op", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "let shown: &\\[&Finding\\] = if verbosity == \"quiet\" \\{\\n &leaks\\n \\} else \\{\\n &active\\n \\};", + "replacement": "let shown: &[&Finding] = &active;", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::quiet_hides_the_advisory_and_leaves_the_exit_alone", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M07", + "rule": "stream-split", + "description": "the machine formats' summary lands on stdout — the human summary pollutes the stream a host parses", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "if matches!\\(format, \"github\" \\| \"msbuild\" \\| \"sarif\"\\) \\{\\n Outcome::new\\(payload, summary, exit\\)\\n \\} else \\{", + "replacement": "if false {\n Outcome::new(payload, summary, exit)\n } else {", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::the_stream_split_is_per_format", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M08", + "rule": "ok-line-with-suppressed", + "description": "the `ok` line fires on `leaks.is_empty()` instead of `shown.is_empty()`, so an advisory-only run claims 'no subscription leaks found' while printing one", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "if shown\\.is_empty\\(\\) \\{\\n let _ = writeln!\\(summary, \"\\{path\\}: ok", + "replacement": "if leaks.is_empty() && notes.is_empty() {\n let _ = writeln!(summary, \"{path}: ok", + "expected_catchers": [ + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M09", + "rule": "verbose-counts-every-finding", + "description": "the verbose breakdown counts only what was SHOWN — the suppressed and hidden findings vanish from the one line that exists to tally them", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "for finding in findings \\{\\n let slot = by_code\\.entry", + "replacement": "for finding in shown.iter().copied() {\n let slot = by_code.entry", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::the_verbose_breakdown_counts_suppressed_findings_too", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M10", + "rule": "sarif-ascii-escape", + "description": "the ASCII escaper is disabled — the CLI's SARIF stdout becomes the BR-V9 goldens' byte shape (ensure_ascii=False) instead of json.dumps' default", + "target": "rust/crates/own-cli/src/sarif.rs", + "pattern": " if ch\\.is_ascii\\(\\) \\{\\n out\\.push\\(ch\\);\\n continue;\\n \\}", + "replacement": " {\n out.push(ch);\n continue;\n }", + "expected_catchers": [ + "own-cli/src/main.rs::sarif::tests::escapes_exactly_as_json_dumps_does", + "own-cli/src/main.rs::sarif::tests::hex_is_lowercase_and_padded", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M11", + "rule": "usage-error-exit-code", + "description": "an invalid flag value exits 1 instead of 2 — a usage error a caller would read as findings", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "const fn usage_error\\(message: String\\) -> Outcome \\{\\n Outcome::new\\(String::new\\(\\), message, 2\\)\\n\\}", + "replacement": "const fn usage_error(message: String) -> Outcome {\n Outcome::new(String::new(), message, 1)\n}", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::a_missing_flag_value_is_its_own_message", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M12", + "rule": "panic-is-exit-70", + "description": "the caught panic exits 101, Rust's default — #262's launcher maps only 70 onto its internal-error path, so 101 would arrive as an unexpected child status", + "target": "rust/crates/own-cli/src/main.rs", + "pattern": " // Under OWNLANG_DEBUG the hook has already printed the payload and\\n // the backtrace; the reference prints the traceback INSTEAD of the\\n // polite line, not as well as it\\. The exit is 70 either way\\.\\n 70", + "replacement": " // Under OWNLANG_DEBUG the hook has already printed the payload and\n // the backtrace; the reference prints the traceback INSTEAD of the\n // polite line, not as well as it. The exit is 70 either way.\n 101", + "expected_catchers": [ + "own-cli-faults/tests/faults.rs::a_catchable_panic_is_one_diagnostic_and_exit_70", + "own-cli-faults/tests/faults.rs::debug_mode_shows_the_backtrace_and_still_exits_70" + ] + }, + { + "id": "M13", + "rule": "panic-is-caught-at-all", + "description": "the panic hook is installed but nothing catches the unwind — the case #261 calls out by name: a hook alone only OBSERVES a panic, and the process still exits 101", + "target": "rust/crates/own-cli/src/main.rs", + "pattern": " let code = match std::panic::catch_unwind\\(run\\) \\{", + "replacement": " let code = match Ok::>(run()) {", + "expected_catchers": [ + "own-cli-faults/tests/faults.rs::a_catchable_panic_is_one_diagnostic_and_exit_70", + "own-cli-faults/tests/faults.rs::debug_mode_shows_the_backtrace_and_still_exits_70" + ] + }, + { + "id": "M14", + "rule": "os-error-line-is-contract", + "description": "the refusal resolves the path instead of echoing argv — a user-visible path silently stops being the one they typed", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": " Err\\(err\\) => return refusal\\(path, &format!\\(\"cannot read \\{path\\}: \\{err\\}\"\\)\\),", + "replacement": " Err(err) => {\n let shown = std::fs::canonicalize(path)\n .map_or_else(|_| path.to_owned(), |p| p.display().to_string());\n return refusal(path, &format!(\"cannot read {shown}: {err}\"));\n }", + "expected_catchers": [ + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M15", + "rule": "docstring-on-stdout", + "description": "the docstring usage answer exits 1 instead of 2, and goes to stderr — the OTHER usage path, which builds its Outcome directly rather than through `usage_error` (M11 named a catcher on this path and could not reach it; this is that path, measured)", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "fn docstring_usage\\(\\) -> Outcome \\{\\n Outcome::new\\(format!\\(\"\\{\\}\\\\n\", text::OWNLANG_DOCSTRING\\), String::new\\(\\), 2\\)\\n\\}", + "replacement": "fn docstring_usage() -> Outcome {\n Outcome::new(String::new(), format!(\"{}\\n\", text::OWNLANG_DOCSTRING), 1)\n}", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::there_is_no_double_dash_separator", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M16", + "rule": "version-byte-parity", + "description": "the Version wrong-type message reverts to serde_json Display — a string regains double quotes, a bool goes lowercase and null becomes `null`, exactly the divergence ruling 2a called a Rust bug", + "target": "rust/crates/own-ir/src/strict.rs", + "pattern": "format!\\(\"OwnIR \\'ownir_version\\' must be an integer, got \\{spelled\\}\"\\)", + "replacement": "format!(\"OwnIR 'ownir_version' must be an integer, got {v}\")", + "expected_catchers": [ + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M17", + "rule": "version-byte-parity", + "description": "the schema-mismatch wording drops `Roslyn` and `Python`, the two words that differed from the reference", + "target": "rust/crates/own-ir/src/strict.rs", + "pattern": "v\\{\\}\\. Build the Roslyn extractor and the Python core from the \\\\\\n same commit", + "replacement": "v{}. Build the extractor and the core from the same commit", + "expected_catchers": [ + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M18", + "rule": "cli-b1-json-parser-detail", + "description": "the CLI-owned JSON wrapper loses the second path — the `{path} is` half the reference bakes in inside `load()`, which is pinned and NOT part of the declared tail", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": "format!\\(\"\\{path\\}: error: \\{path\\} is not valid JSON: \\{detail\\}\\\\n\"\\)", + "replacement": "format!(\"{path}: error: not valid JSON: {detail}\\n\")", + "expected_catchers": [ + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ] + }, + { + "id": "M19", + "rule": "cli-b1-json-parser-detail", + "description": "the boundary guard swallows its own structural drift: a Json rejection that lost `own-ir`'s internal prefix is passed through as rc 2 instead of failing onto the internal-error path — the lock left out of the door", + "target": "rust/crates/own-cli/src/ownir.rs", + "pattern": " None => Outcome::internal_error\\(&format!\\(\\n \"the strict door returned a Json rejection whose message does not begin \\\\\\n with \\{RUST_JSON_PREFIX:\\?\\}: \\{:\\?\\}\",\\n refused\\.message\\n \\)\\),", + "replacement": " None => refusal(path, &refused.message),", + "expected_catchers": [ + "own-cli/src/main.rs::ownir::tests::a_json_rejection_that_lost_its_prefix_is_an_internal_error_not_rc2" + ] + }, + { + "id": "M20", + "rule": "version-byte-parity", + "description": "a repeated dict key is APPENDED instead of rebound in place, so it appears twice and in the wrong position — `{'b': 3, 'a': 2}` becomes `{'b': 1, 'a': 2, 'b': 3}`. CPython's decoder rebinds; the position comes from the first binding and the value from the last", + "target": "rust/crates/own-ir/src/pyrepr.rs", + "pattern": " if let Some\\(slot\\) = pairs\\.iter_mut\\(\\)\\.find\\(\\|\\(k, _\\)\\| \\*k == key\\) \\{\\n slot\\.1 = value;\\n \\} else \\{\\n pairs\\.push\\(\\(key, value\\)\\);\\n \\}", + "replacement": " pairs.push((key, value));", + "expected_catchers": [ + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference", + "own-ir/src/lib.rs::pyrepr::tests::a_repeated_key_is_rebound_in_place" + ] + }, + { + "id": "M21", + "rule": "version-byte-parity", + "description": "the float exponent loses CPython's zero padding — `1e-06` becomes `1e-6` and `1e+16` becomes `1e16`. This is the exact spelling R2 shipped: the four hand-picked controls it measured had no exponent, so nothing saw it", + "target": "rust/crates/own-ir/src/pyrepr.rs", + "pattern": "format!\\(\"\\{sign\\}\\{lead\\}\\{point\\}e\\{exp_sign\\}\\{magnitude:02\\}\"\\)", + "replacement": "format!(\"{sign}{lead}{point}e{magnitude}\")", + "expected_catchers": [ + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference", + "own-ir/src/lib.rs::pyrepr::tests::matches_cpython_float_repr" + ] + }, + { + "id": "M22", + "rule": "version-byte-parity", + "description": "the gate stops re-reading the raw document and spells the rejection from the parsed `Value` alone — object keys come back sorted and an oversized integer takes the wrong-type arm as `1e+31` instead of the reference's mismatch arm. One mutation, because one line is what the whole raw re-read hangs on", + "target": "rust/crates/own-ir/src/strict.rs", + "pattern": " let raw = source\\.and_then\\(crate::pyrepr::version_value\\);", + "replacement": " let raw: Option = None;", + "expected_catchers": [ + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" + ] + }, + { + "id": "M23", + "rule": "version-byte-parity", + "description": "the float digits come from Rust's SHORTEST formatter rather than its exact formatter at the shortest length, so a value exactly midway between two candidates rounds away from CPython's round-half-to-even — `-1128910513108089.2` becomes `-1128910513108089.3`. Reachable by roughly one double in 3 000 and by none of the controls chosen by hand", + "target": "rust/crates/own-ir/src/pyrepr.rs", + "pattern": " let rendered = format!\\(\"\\{x:\\.\\*e\\}\", significant\\.saturating_sub\\(1\\)\\);", + "replacement": " let rendered = shortest.clone();", + "expected_catchers": [ + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" + ] + }, + { + "id": "M24", + "rule": "cli-b1-json-parser-detail", + "description": "the CLI-B1 eligibility guard judges a file it reaches for instead of the bytes it was handed, so the negative control's two runs stop differing. This mutates the EVIDENCE, not the product, and it is here for one reason: #261 R3b replaced a negative control that read a different case at a different path, and a control held to one argv and one path is only worth more than that one if it actually fires when the guard stops reading the supplied bytes. Non-vacuity itself is a property of the control's construction — one case, two byte sequences — and is not mutation-provable; this shows the construction is load-bearing", + "target": "rust/crates/own-cli/tests/replay.rs", + "pattern": " // Steps 1 and 2\\. Invalid UTF-8 belongs to ruling 1, never here\\.\\n let text = std::str::from_utf8\\(facts\\)\\.map_err\\(\\|e\\| \\{", + "replacement": " let facts = &std::fs::read(\n fixture_dir()\n .join(\"inputs\")\n .join(\"not_json_truncated.facts.broken\"),\n )\n .unwrap_or_default();\n let text = std::str::from_utf8(facts).map_err(|e| {", + "expected_catchers": [ + "own-cli/tests/replay.rs::cli_b1_flips_on_the_facts_bytes_and_nothing_else" + ] + } + ] +} diff --git a/docs/evidence/p022-cli-1.result.json b/docs/evidence/p022-cli-1.result.json new file mode 100644 index 00000000..55aa905d --- /dev/null +++ b/docs/evidence/p022-cli-1.result.json @@ -0,0 +1,248 @@ +{ + "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-cli-1", + "definition": "docs/evidence/p022-cli-1.json", + "definition_sha256": "f86d83855bd7cd041cd7880370d3371142b9eff57b562061d44e9b2931fdb573", + "source_commit": "f932374ed8a5fc4731c37c3ce1cf5a071c1843a5", + "dirty": false, + "recorded_at": "2026-09-08T11:36:04Z", + "layers": [ + "own-cli", + "own-cli-faults", + "own-ir", + "rust-rest" + ], + "command": "every layer the definition declares, for every mutation", + "control": { + "id": "M00", + "outcome": "survived", + "catchers": [], + "elapsed_seconds": 5.3 + }, + "mutations": [ + { + "id": "M01", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::an_advisory_alone_never_fails_the_run", + "own-cli/src/main.rs::ownir::tests::quiet_hides_the_advisory_and_leaves_the_exit_alone", + "own-cli/src/main.rs::ownir::tests::sarif_carries_shown_plus_suppressed", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 2.8 + }, + { + "id": "M02", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::a_suppression_is_counted_but_does_not_fail_the_run", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 5.6 + }, + { + "id": "M03", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::exit_is_independent_of_severity_and_verbosity", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 5.7 + }, + { + "id": "M04", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::sarif_carries_shown_plus_suppressed", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 5.7 + }, + { + "id": "M05", + "outcome": "caught", + "catchers": [ + "own-cli-faults/tests/faults.rs::without_the_environment_variable_the_same_invocation_is_clean", + "own-cli/src/main.rs::ownir::tests::a_suppression_is_counted_but_does_not_fail_the_run", + "own-cli/src/main.rs::ownir::tests::the_stream_split_is_per_format", + "own-cli/src/main.rs::ownir::tests::the_summary_pluralizes_on_anything_but_one", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 6.0 + }, + { + "id": "M06", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::quiet_hides_the_advisory_and_leaves_the_exit_alone", + "own-cli/src/main.rs::ownir::tests::sarif_carries_shown_plus_suppressed", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 5.4 + }, + { + "id": "M07", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::the_stream_split_is_per_format", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 5.5 + }, + { + "id": "M08", + "outcome": "caught", + "catchers": [ + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 5.4 + }, + { + "id": "M09", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::the_verbose_breakdown_counts_suppressed_findings_too", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 5.8 + }, + { + "id": "M10", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::sarif::tests::escapes_exactly_as_json_dumps_does", + "own-cli/src/main.rs::sarif::tests::hex_is_lowercase_and_padded", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 6.0 + }, + { + "id": "M11", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::a_missing_flag_value_is_its_own_message", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 5.8 + }, + { + "id": "M12", + "outcome": "caught", + "catchers": [ + "own-cli-faults/tests/faults.rs::a_catchable_panic_is_one_diagnostic_and_exit_70", + "own-cli-faults/tests/faults.rs::debug_mode_shows_the_backtrace_and_still_exits_70" + ], + "elapsed_seconds": 6.1 + }, + { + "id": "M13", + "outcome": "caught", + "catchers": [ + "own-cli-faults/tests/faults.rs::a_catchable_panic_is_one_diagnostic_and_exit_70", + "own-cli-faults/tests/faults.rs::debug_mode_shows_the_backtrace_and_still_exits_70" + ], + "elapsed_seconds": 6.0 + }, + { + "id": "M14", + "outcome": "caught", + "catchers": [ + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 5.8 + }, + { + "id": "M15", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::there_is_no_double_dash_separator", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 5.9 + }, + { + "id": "M16", + "outcome": "caught", + "catchers": [ + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte", + "own-ir/tests/version_repr_census.rs::negative_zero_below_the_top_level_matches_the_reference", + "own-ir/tests/version_repr_census.rs::the_declared_divergences_are_exactly_the_declared_ones", + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" + ], + "elapsed_seconds": 6.3 + }, + { + "id": "M17", + "outcome": "caught", + "catchers": [ + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte", + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" + ], + "elapsed_seconds": 7.7 + }, + { + "id": "M18", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::a_json_rejection_that_lost_its_prefix_is_an_internal_error_not_rc2", + "own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte" + ], + "elapsed_seconds": 7.8 + }, + { + "id": "M19", + "outcome": "caught", + "catchers": [ + "own-cli/src/main.rs::ownir::tests::a_json_rejection_that_lost_its_prefix_is_an_internal_error_not_rc2" + ], + "elapsed_seconds": 5.6 + }, + { + "id": "M20", + "outcome": "caught", + "catchers": [ + "own-ir/src/lib.rs::pyrepr::tests::a_repeated_key_is_rebound_in_place", + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" + ], + "elapsed_seconds": 7.3 + }, + { + "id": "M21", + "outcome": "caught", + "catchers": [ + "own-ir/src/lib.rs::pyrepr::tests::an_oversized_integer_keeps_its_digits", + "own-ir/src/lib.rs::pyrepr::tests::matches_cpython_float_repr", + "own-ir/src/lib.rs::pyrepr::tests::the_exponent_padding_is_the_defect_r2_left", + "own-ir/src/lib.rs::pyrepr::tests::the_literal_decides_int_or_float", + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" + ], + "elapsed_seconds": 8.8 + }, + { + "id": "M22", + "outcome": "caught", + "catchers": [ + "own-ir/tests/version_repr_census.rs::negative_zero_below_the_top_level_matches_the_reference", + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" + ], + "elapsed_seconds": 9.6 + }, + { + "id": "M23", + "outcome": "caught", + "catchers": [ + "own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference" + ], + "elapsed_seconds": 8.7 + }, + { + "id": "M24", + "outcome": "caught", + "catchers": [ + "own-cli/tests/replay.rs::cli_b1_flips_on_the_facts_bytes_and_nothing_else" + ], + "elapsed_seconds": 6.2 + } + ] +} diff --git a/docs/generated/p022-cli-census.md b/docs/generated/p022-cli-census.md new file mode 100644 index 00000000..b6818dd6 --- /dev/null +++ b/docs/generated/p022-cli-census.md @@ -0,0 +1,59 @@ + + +# P-022 step 7b (#261) — the `own-cli ownir` contract, counted + +`tests/fixtures/cli_ownir/` is the frozen CLI contract, authoritative via `python tests/test_cli_ownir_fixtures.py --write` on Linux and replayed against the built binary with **zero Python** by `own-cli/tests/replay.rs` on Linux and Windows CI. Every case names the rule it is the control for and the oracle that authored its bytes; both tables below are read from the manifest. + +| measure | value | +|------------------------------------|------:| +| frozen cases | 88 | + +## By oracle + +The oracle boundary is #261's C-1, and it is the SURFACE rather than the reference's internal print branch. + +| oracle | cases | what authored the bytes | +|---|------:|---| +| `owen-convention` | 7 | no Python byte oracle exists: the top-level shell, authored once from the `owen` convention and shared with the binary | +| `python` | 77 | an executed `python -m ownlang ownir` run | +| `python-docstring` | 4 | the same, where the bytes are the WHOLE module docstring on stdout — frozen as measured and flagged, so the owner can declare that class a defect knowing what was frozen | + +## By rule + +A case may be the control for more than one rule, so these do not sum to the case count — they say how much evidence each rule has, which is the question. + +| rule | cases | +|---|------:| +| `advisory-never-fails-the-run` | 3 | +| `cli-b1-json-parser-detail` | 3 | +| `cli-b1-negative-control` | 1 | +| `debug-env-is-inert-when-nothing-crashes` | 1 | +| `declared-defect` | 1 | +| `docstring-on-stdout` | 4 | +| `duplicate-flag` | 1 | +| `empty-reason-never-suppresses` | 1 | +| `equals-spelling` | 3 | +| `exit-independent-of-severity` | 32 | +| `invalid-flag-value` | 6 | +| `machine-stdout-empty-when-clean` | 2 | +| `missing-flag-value` | 3 | +| `no-double-dash-separator` | 1 | +| `nonascii-in-file-field` | 3 | +| `nonascii-in-path` | 2 | +| `ok-line` | 4 | +| `ok-line-with-suppressed` | 3 | +| `os-error-placeholder` | 4 | +| `ownir-format-scope` | 1 | +| `path-echoed-as-given` | 1 | +| `quiet-hides-advisory` | 11 | +| `sarif-ascii-escape` | 2 | +| `sarif-carries-shown-plus-suppressed` | 7 | +| `stdin-out-of-contract` | 1 | +| `stream-split` | 36 | +| `strict-door` | 12 | +| `summary-suppressed-tail` | 26 | +| `unknown-flag-is-positional` | 2 | +| `usage-owen-shape` | 7 | +| `verbose-counts-every-finding` | 13 | +| `version-byte-parity` | 5 | +| `windows-path-form` | 2 | diff --git a/docs/generated/p022-cli-mutations.md b/docs/generated/p022-cli-mutations.md new file mode 100644 index 00000000..ec11e8cf --- /dev/null +++ b/docs/generated/p022-cli-mutations.md @@ -0,0 +1,51 @@ + + +# P-022 step 7b (#261) — mutation campaigns + +The production OwnIR executable's contract: the display policy the reference's `cmd_ownir` defines, the CLI's own SARIF serialization conventions (which are NOT the BR-V9 fixture emitter's), the usage-error exit codes, and the process contract — a catchable panic is one actionable diagnostic and exit 70, never 101. Every mutation is a plausible MISREADING of the reference rather than a syntactic accident: each one would pass a reviewer who had read the module docstring instead of the code. Every mutation edits a **production** surface (P-022 discipline 2) and every declared layer runs for every mutation (discipline 3: no fail-fast) — including the layer that enables the off-by-default `fault-injection` feature, without which the two failure-mode controls could not be seen to catch anything. The counts are derived from the recorded run by `scripts/mutate_campaign.summarize()`, never typed. + +## 261.B — `own-cli ownir`: the display policy, the CLI's SARIF bytes, the usage exit codes and the process contract + +Campaign `p022-cli-1` — #261 261.B, the production OwnIR executable `own-cli ownir`: the display policy the reference's cmd_ownir defines (which findings are shown, the summary and `ok` lines, the verbosity variants, the stream split), the CLI's own SARIF serialization conventions (ASCII escaping, and the suppressed findings that ride in the results), the usage-error exit codes, and the process contract (a catchable panic is one diagnostic and exit 70, never 101). Every mutation is a plausible misreading of the reference rather than a syntactic accident: each one would pass a reviewer who had read the docstring instead of the code. The repair pass (#261 R2/R3) adds the strict-door families: the Version messages, which ruling 2a FIXED to byte parity rather than declaring, and the CLI-B1 boundary, whose CLI-owned wrapper is pinned and whose guard must fail onto rc 70 rather than swallow its own structural drift as the declared tail. The second repair pass (#261 R2b/R3b) adds the classes those families' first measurement could not reach: CPython's dict ORDER and its float SPELLING, the arbitrary-precision integer that changes which branch the reference takes, the round-half-to-even tie in the shortest round-trip digits, and the CLI-B1 negative control's own load-bearingness. + +Definition: `docs/evidence/p022-cli-1.json` (sha256 `f86d83855bd7cd04…`, 24 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-cli-1.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. + +| measure | value | +|--------------------------------------------------|---| +| recorded at commit | `f932374ed8a5fc4731c37c3ce1cf5a071c1843a5` | +| layers run (every one, for every mutation) | `own-cli`, `own-cli-faults`, `own-ir`, `rust-rest` | +| mutations | 24 | +| caught | 24 | +| 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 | +|---|---|---|---|---| +| M01 | exit-independent-of-severity | an advisory-only document exits 1 — the docstring's 'non-zero if any error-level diagnostic' read as the contract, instead of `1 if leaks else 0` | caught | `own-cli/src/main.rs::ownir::tests::an_advisory_alone_never_fails_the_run`
`own-cli/src/main.rs::ownir::tests::quiet_hides_the_advisory_and_leaves_the_exit_alone`
`own-cli/src/main.rs::ownir::tests::sarif_carries_shown_plus_suppressed`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M02 | suppression-is-counted-not-a-verdict | a suppressed finding fails the run — `[OwnIgnore]` counted in the exit code instead of only in the tally | caught | `own-cli/src/main.rs::ownir::tests::a_suppression_is_counted_but_does_not_fail_the_run`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M03 | exit-independent-of-severity | `--severity warning` flips the exit to 0 — a presentation choice mistaken for a verdict | caught | `own-cli/src/main.rs::ownir::tests::exit_is_independent_of_severity_and_verbosity`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M04 | sarif-carries-shown-plus-suppressed | SARIF drops the suppressed findings — the human stream's rule applied to a machine document, losing what a consumer is meant to count | caught | `own-cli/src/main.rs::ownir::tests::sarif_carries_shown_plus_suppressed`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M05 | summary-pluralization | the summary pluralizes on `> 1` instead of `!= 1`, so a clean run reads '0 finding' | caught | `own-cli-faults/tests/faults.rs::without_the_environment_variable_the_same_invocation_is_clean`
`own-cli/src/main.rs::ownir::tests::a_suppression_is_counted_but_does_not_fail_the_run`
`own-cli/src/main.rs::ownir::tests::the_stream_split_is_per_format`
`own-cli/src/main.rs::ownir::tests::the_summary_pluralizes_on_anything_but_one`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M06 | quiet-hides-advisory | `quiet` stops hiding the advisory notes — the verbosity option silently becomes a no-op | caught | `own-cli/src/main.rs::ownir::tests::quiet_hides_the_advisory_and_leaves_the_exit_alone`
`own-cli/src/main.rs::ownir::tests::sarif_carries_shown_plus_suppressed`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M07 | stream-split | the machine formats' summary lands on stdout — the human summary pollutes the stream a host parses | caught | `own-cli/src/main.rs::ownir::tests::the_stream_split_is_per_format`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M08 | ok-line-with-suppressed | the `ok` line fires on `leaks.is_empty()` instead of `shown.is_empty()`, so an advisory-only run claims 'no subscription leaks found' while printing one | caught | `own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M09 | verbose-counts-every-finding | the verbose breakdown counts only what was SHOWN — the suppressed and hidden findings vanish from the one line that exists to tally them | caught | `own-cli/src/main.rs::ownir::tests::the_verbose_breakdown_counts_suppressed_findings_too`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M10 | sarif-ascii-escape | the ASCII escaper is disabled — the CLI's SARIF stdout becomes the BR-V9 goldens' byte shape (ensure_ascii=False) instead of json.dumps' default | caught | `own-cli/src/main.rs::sarif::tests::escapes_exactly_as_json_dumps_does`
`own-cli/src/main.rs::sarif::tests::hex_is_lowercase_and_padded`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M11 | usage-error-exit-code | an invalid flag value exits 1 instead of 2 — a usage error a caller would read as findings | caught | `own-cli/src/main.rs::ownir::tests::a_missing_flag_value_is_its_own_message`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M12 | panic-is-exit-70 | the caught panic exits 101, Rust's default — #262's launcher maps only 70 onto its internal-error path, so 101 would arrive as an unexpected child status | caught | `own-cli-faults/tests/faults.rs::a_catchable_panic_is_one_diagnostic_and_exit_70`
`own-cli-faults/tests/faults.rs::debug_mode_shows_the_backtrace_and_still_exits_70` | +| M13 | panic-is-caught-at-all | the panic hook is installed but nothing catches the unwind — the case #261 calls out by name: a hook alone only OBSERVES a panic, and the process still exits 101 | caught | `own-cli-faults/tests/faults.rs::a_catchable_panic_is_one_diagnostic_and_exit_70`
`own-cli-faults/tests/faults.rs::debug_mode_shows_the_backtrace_and_still_exits_70` | +| M14 | os-error-line-is-contract | the refusal resolves the path instead of echoing argv — a user-visible path silently stops being the one they typed | caught | `own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M15 | docstring-on-stdout | the docstring usage answer exits 1 instead of 2, and goes to stderr — the OTHER usage path, which builds its Outcome directly rather than through `usage_error` (M11 named a catcher on this path and could not reach it; this is that path, measured) | caught | `own-cli/src/main.rs::ownir::tests::there_is_no_double_dash_separator`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M16 | version-byte-parity | the Version wrong-type message reverts to serde_json Display — a string regains double quotes, a bool goes lowercase and null becomes `null`, exactly the divergence ruling 2a called a Rust bug | caught | `own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte`
`own-ir/tests/version_repr_census.rs::negative_zero_below_the_top_level_matches_the_reference`
`own-ir/tests/version_repr_census.rs::the_declared_divergences_are_exactly_the_declared_ones`
`own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | +| M17 | version-byte-parity | the schema-mismatch wording drops `Roslyn` and `Python`, the two words that differed from the reference | caught | `own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte`
`own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | +| M18 | cli-b1-json-parser-detail | the CLI-owned JSON wrapper loses the second path — the `{path} is` half the reference bakes in inside `load()`, which is pinned and NOT part of the declared tail | caught | `own-cli/src/main.rs::ownir::tests::a_json_rejection_that_lost_its_prefix_is_an_internal_error_not_rc2`
`own-cli/tests/replay.rs::replays_the_whole_cli_contract_byte_for_byte` | +| M19 | cli-b1-json-parser-detail | the boundary guard swallows its own structural drift: a Json rejection that lost `own-ir`'s internal prefix is passed through as rc 2 instead of failing onto the internal-error path — the lock left out of the door | caught | `own-cli/src/main.rs::ownir::tests::a_json_rejection_that_lost_its_prefix_is_an_internal_error_not_rc2` | +| M20 | version-byte-parity | a repeated dict key is APPENDED instead of rebound in place, so it appears twice and in the wrong position — `{'b': 3, 'a': 2}` becomes `{'b': 1, 'a': 2, 'b': 3}`. CPython's decoder rebinds; the position comes from the first binding and the value from the last | caught | `own-ir/src/lib.rs::pyrepr::tests::a_repeated_key_is_rebound_in_place`
`own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | +| M21 | version-byte-parity | the float exponent loses CPython's zero padding — `1e-06` becomes `1e-6` and `1e+16` becomes `1e16`. This is the exact spelling R2 shipped: the four hand-picked controls it measured had no exponent, so nothing saw it | caught | `own-ir/src/lib.rs::pyrepr::tests::an_oversized_integer_keeps_its_digits`
`own-ir/src/lib.rs::pyrepr::tests::matches_cpython_float_repr`
`own-ir/src/lib.rs::pyrepr::tests::the_exponent_padding_is_the_defect_r2_left`
`own-ir/src/lib.rs::pyrepr::tests::the_literal_decides_int_or_float`
`own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | +| M22 | version-byte-parity | the gate stops re-reading the raw document and spells the rejection from the parsed `Value` alone — object keys come back sorted and an oversized integer takes the wrong-type arm as `1e+31` instead of the reference's mismatch arm. One mutation, because one line is what the whole raw re-read hangs on | caught | `own-ir/tests/version_repr_census.rs::negative_zero_below_the_top_level_matches_the_reference`
`own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | +| M23 | version-byte-parity | the float digits come from Rust's SHORTEST formatter rather than its exact formatter at the shortest length, so a value exactly midway between two candidates rounds away from CPython's round-half-to-even — `-1128910513108089.2` becomes `-1128910513108089.3`. Reachable by roughly one double in 3 000 and by none of the controls chosen by hand | caught | `own-ir/tests/version_repr_census.rs::the_version_family_is_byte_exact_with_the_reference` | +| M24 | cli-b1-json-parser-detail | the CLI-B1 eligibility guard judges a file it reaches for instead of the bytes it was handed, so the negative control's two runs stop differing. This mutates the EVIDENCE, not the product, and it is here for one reason: #261 R3b replaced a negative control that read a different case at a different path, and a control held to one argv and one path is only worth more than that one if it actually fires when the guard stops reading the supplied bytes. Non-vacuity itself is a property of the control's construction — one case, two byte sequences — and is not mutation-provable; this shows the construction is load-bearing | caught | `own-cli/tests/replay.rs::cli_b1_flips_on_the_facts_bytes_and_nothing_else` | diff --git a/docs/generated/p022-coord-census.md b/docs/generated/p022-coord-census.md index 6dcc1cc8..a464d5c1 100644 --- a/docs/generated/p022-coord-census.md +++ b/docs/generated/p022-coord-census.md @@ -10,8 +10,8 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | measure | value | |------------------------------------|------:| -| JSON files scanned | 331 | -| coordinate slots found | 2193 | +| JSON files scanned | 430 | +| coordinate slots found | 2197 | ## By value class @@ -21,7 +21,7 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | `below-1` | 19 | 19 | | `bool` | 14 | 14 | | `float` | 2 | 2 | -| `in-domain` | 1748 | 953 | +| `in-domain` | 1752 | 957 | | `negative` | 23 | 23 | | `null` | 227 | 7 | | `outside-int64` | 14 | 14 | @@ -139,6 +139,8 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | `(root)` | `order_cases[].emitted[].diagnostic.line` | `in-domain` | — | 15 | 1 | — | | `(root)` | `render_cases[].diagnostic.evidence[].line` | `in-domain` | — | 9 | 1 | — | | `(root)` | `render_cases[].diagnostic.line` | `in-domain` | — | 26 | 1 | — | +| `cli_ownir/inputs` | `components[].subscriptions[].line` | `in-domain` | yes | 3 | 3 | — | +| `cli_ownir/inputs/pa th ünïcødé` | `components[].subscriptions[].line` | `in-domain` | yes | 1 | 1 | — | | `lowered` | `components[].subscriptions[].line` | `in-domain` | yes | 24 | 10 | — | | `lowered` | `functions[].[].line` | `in-domain` | yes | 150 | 41 | — | | `lowered` | `functions[].params[].line` | `in-domain` | yes | 20 | 10 | — | diff --git a/docs/notes/p022-cli-ownir.md b/docs/notes/p022-cli-ownir.md new file mode 100644 index 00000000..db24ee4b --- /dev/null +++ b/docs/notes/p022-cli-ownir.md @@ -0,0 +1,876 @@ +# P-022 step 7b (#261, 261.B) — `own-cli ownir`: the measured reference, the frozen CLI contract + +> **Scope of this note.** It is the record of the **production Rust OwnIR +> executable** — the binary `own-cli` with its single subcommand `ownir` — and +> of the measurement that produced its fixture. The contract it runs under is +> #261's ratified decision packet (261.A: C-1..C-5 and the stdin / panic / +> death / SIGINT rulings), reproduced in that issue's body. **This note may not +> reopen any of it.** What #345 adds to the same binary later (`cfg`, +> `summaries`, `explain`, `.own check`, `emit`) is deliberately absent here, and +> `report` is struck (C-2), not deferred. +> +> Python is the oracle and does not move: nothing under `ownlang/` changed in +> this work, and no existing fixture under `tests/fixtures/` was regenerated or +> relocated. + +**Where the numbers are.** Every count this work produced — fixture cases by +rule and by oracle class, the mutation campaign's outcome — lives in +[`docs/generated/p022-cli-census.md`](../generated/p022-cli-census.md) and +[`docs/generated/p022-cli-mutations.md`](../generated/p022-cli-mutations.md), +rendered by `scripts/render_checkpoint_status.py`, and is reached from here by +link. No count is typed into this prose. + +--- + +## §1 — What the reference actually does, measured + +Everything below was **executed** against `python -m ownlang ownir` on this +tree, not read off the module docstring. Where the docstring and the code +disagree, the code is the contract and the fixture proves it. + +**Host of record.** Linux (`ubuntu`-class container), CPython 3.11.15, repo +root as the working directory. The CI `tests` job re-verifies every +`oracle: "python"` case against 3.11/3.12/3.13 on `ubuntu-latest`. + +### 1.1 The usage surface after `ownir` + +Every one of these is *after* `ownir` is selected, so by C-1 every one of them +is contract **as measured** — the reference's stream, text and exit — with the +single declared-defect exception noted below. + +| invocation | stream | exit | what comes out | +|---|---|---|---| +| `ownir` (no positional) | **stdout** | 2 | the whole module docstring | +| `ownir ` (two positionals) | **stdout** | 2 | the whole module docstring | +| `ownir --bogus ` (unknown flag **with** a path) | **stdout** | 2 | the whole module docstring | +| `ownir -- ` (`--` is not a separator) | **stdout** | 2 | the whole module docstring | +| `ownir --bogus` (unknown flag **without** a path) | stderr | 2 | `--bogus: error: cannot read --bogus: ` | +| `ownir --format` (missing value) | stderr | 2 | `--format requires a value` | +| `ownir --severity` / `--verbosity` | stderr | 2 | `--severity requires a value` / `--verbosity requires a value` | +| `ownir --format x` | stderr | 2 | `unknown --format 'x' (choose: github, human, json, msbuild, sarif)` | +| `ownir --format=` (empty value) | stderr | 2 | `unknown --format '' (choose: …)` | +| `ownir --format --severity` (flag as value) | stderr | 2 | `unknown --format '--severity' (choose: …)` | +| `ownir --severity x` | stderr | 2 | `unknown --severity 'x' (choose: error, warning)` | +| `ownir --verbosity x` | stderr | 2 | `unknown --verbosity 'x' (choose: normal, quiet, verbose)` | +| `ownir --format json` | stderr | 2 | `ownir --format must be one of github/human/msbuild/sarif (got 'json')` | +| `ownir --format=sarif` (the `=` spelling) | — | 0/1 | accepted, identical to the space spelling | +| `ownir --format=human --format=sarif` | — | 0/1 | **last flag wins**; a duplicate is not an error | +| `ownir --help` | stderr | 2 | `--help: error: cannot read --help: ` — **declared a defect (C-1)** | +| `ownir -` | stderr | 2 | `-: error: cannot read -: ` — **out of contract (stdin ruling)** | + +Two consequences are made visible rather than hidden, exactly as C-1 requires: + +1. **The docstring-on-stdout class.** A positional-count error or an unknown + argument prints the *entire* module docstring — the usage text of the whole + PoC CLI, `report` and `emit` included — to **stdout**, with exit 2. Under + C-1's boundary those bytes are the contract as measured. They are frozen and + marked `oracle: "python-docstring"` in the manifest. The four cases in that + class are, by fixture name: + `usage-no-positional`, `usage-two-positionals`, + `usage-unknown-flag-with-path`, `usage-double-dash-not-a-separator`. + **The owner may declare that exact class a defect; this task does not make + that call.** +2. **An unknown flag is not an error to the reference's parser.** It becomes a + positional. With a real path present that is *two* positionals (the + docstring); alone it is *one* positional and goes down the ordinary + `cmd_ownir` path, where opening a file named `--bogus` fails. The two halves + of that behaviour are separate fixture cases on purpose. + +`--format x` lists **`json`** among the choices (it passes the global +`_FORMATS` gate) and is then rejected on the `ownir` branch by its own message, +which names the four surfaces in the order `github/human/msbuild/sarif`. Both +messages are frozen. + +### 1.2 The display policy — four documents × four formats × two severities × three verbosities + +Measured over the full 96-cell matrix. The documents are existing fixtures +wherever one had the shape; three inputs were added under +`tests/fixtures/cli_ownir/inputs/` because no existing document had the shape at +all (named below). + +| shape | document | exit | +|---|---|---| +| clean | `tests/fixtures/verdict_renders/render_empty.facts.json` | 0 | +| leaky | `tests/fixtures/verdict_renders/render_columns.facts.json` | 1 | +| advisory-only (OWN052, no leak) | `tests/fixtures/verdict_renders/render_anchorless.facts.json` | 0 | +| all four bands (leak + intrinsic-warning leak + advisory + suppressed) | `tests/fixtures/verdict_renders/render_tiers_and_levels.facts.json` | 1 | +| **suppressed-only** (new) | `cli_ownir/inputs/suppressed_only.facts.json` | 0 | +| **non-ASCII `file`** (new) | `cli_ownir/inputs/nonascii_file.facts.json` | 1 | +| **empty-string `ignore_reason`** (new) | `cli_ownir/inputs/empty_ignore_reason.facts.json` | 1 | +| **spaced + non-ASCII *path*** (new) | `cli_ownir/inputs/pa th ünïcødé/facts.json` | 1 | + +What the matrix proves, each one a named fixture control: + +- **The exit code is independent of `--severity`.** Across all 24 cells per + document the observed exit set is a *singleton*: clean `{0}`, leaky `{1}`, + advisory-only `{0}`, all-bands `{1}`. The docstring's "non-zero if any + error-level diagnostic" is **not** the contract; `return 1 if leaks else 0` + is, where `leaks` are the non-advisory, non-suppressed findings. `--verbosity` + does not move it either. +- **`quiet` hides advisories and leaves the exit alone.** The stream loses the + advisory lines and the summary reads `(N advisory hidden)` instead of + `, N advisory (CODES)`. Nothing else changes. +- **The `ok` line fires on `not shown`, suppressions included.** The + suppressed-only document prints + `: ok — no subscription leaks found` *and* + `0 findings, 1 suppressed ([OwnIgnore]).` — both, in one run, at exit 0. The + em dash is U+2014 and is not ASCII. +- **The verbose `by code:` breakdown counts every finding.** On the all-bands + document it reports `OWN001=3` while only two OWN001 lines are shown; on the + suppressed-only document it reports `OWN001=1` while *nothing* is shown. It + iterates `findings`, never `shown`. +- **The stream split is per format.** `human` writes findings *and* the summary + to stdout (stderr is empty); `github`, `msbuild` and `sarif` write the + machine payload to stdout and the summary to **stderr**. +- **A clean `github`/`msbuild` run writes zero bytes to stdout.** Verified for + every severity/verbosity cell of the clean document. +- **SARIF carries `shown + suppressed`.** On the all-bands document `quiet` + yields three results (2 leaks + 1 suppressed) and `normal` four (3 active + 1 + suppressed). +- **An empty-string `ignore_reason` does not suppress** (BR-V6): the document + exits 1 with the finding shown. Measured, not assumed. + +### 1.3 The SARIF byte shape on stdout is **not** the BR-V9 golden's + +This is the one place where two byte shapes come out of one builder, and the +difference is load-bearing: + +| surface | emitter | non-ASCII | +|---|---|---| +| the CLI's `--format sarif` stdout | `json.dumps(…, indent=2)` — `ensure_ascii` **defaults to True** | escaped `\uXXXX` | +| `tests/fixtures/verdict_renders/*.renders.json` (BR-V9) | the fixture writer, `ensure_ascii=False` | literal UTF-8 bytes | + +Measured on the all-bands document: the CLI's SARIF stdout is **pure ASCII**, +carries the six ASCII characters `\u2014` where the message has an em dash, and ends in exactly one +`\n`. The BR-V9 golden for the same document carries the literal `e2 80 94` +bytes and no escape. **The builder is not touched.** The CLI path gets its own +ASCII-escaping serializer; `own_bridge::build_sarif` stays the single source of +the document's *shape*. + +The astral case is measured too: with `U+1F600` in a `file` field the CLI's +SARIF emits the **surrogate pair** `\ud83d\ude00`, while the `human`/`github`/ +`msbuild` lines carry the character itself. + +### 1.4 Byte-level inputs, and the one that is not pinnable + +| input | exit | stderr | +|---|---|---| +| missing file | 2 | `: error: cannot read : ` | +| a directory | 2 | `: error: cannot read : ` | +| empty file | 2 | `: error: is not valid JSON: Expecting value: line 1 column 1 (char 0)` | +| truncated JSON | 2 | `… is not valid JSON: Expecting value: line 1 column 37 (char 36)` | +| UTF-8 BOM | 2 | `… is not valid JSON: Unexpected UTF-8 BOM (decode using utf-8-sig): …` | +| root is not an object | 2 | `: error: OwnIR root must be a JSON object` | +| `ownir_version` mismatch | 2 | `: error: OwnIR facts are schema v99, but this core understands v0. Build the Roslyn extractor and the Python core from the same commit — …` | +| **invalid UTF-8** | **70** | `ownlang: internal error: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 45: invalid start byte` + the second line | + +**Invalid UTF-8 exits 70 — a declared reference defect (§5.1).** `load()` +converts `OSError` and `JSONDecodeError` and nothing else; a +`UnicodeDecodeError` is neither, so it escapes to `run()`'s catch-all. Ruling 1 +records it as a defect of the reference, excluded from #261's byte contract +pending a Python-first normalization before public cutover. No fixture case +freezes it. + +**The other two refusal families are settled, differently (§5.2).** The +**Version** family was a Rust bug in our own text and is now byte-exact (ruling +2a). The **JSON-syntax** family keeps its CLI-owned wrapper byte-exact and +declares only the parser library's own detail, under the named boundary +**CLI-B1** (ruling 2b). Every other family — shape, vocabulary, identity, +location — is pinned byte-exact. + +### 1.5 OS error text — the one placeholder + +The `cannot read : ` tail is platform-native: Linux produces +`[Errno 2] No such file or directory: ''` and `[Errno 21] Is a directory: +''`; Windows refuses a directory with `[Errno 13] Permission denied`. + +**The decision, written down:** the contract is the line **up to and including** +`cannot read : `, byte-exact; the tail is recorded per platform in the +case file and matched through the single placeholder ``, which +consumes the rest of that line and must consume something non-empty. This is +the same choice `tests/test_cli_contract.py` already made with its +`"cannot read"` substring, made explicit and narrower. `` is the +**only** placeholder the fixture format admits, and it may appear only on a line +that carries an OS error text. + +### 1.6 Paths + +Measured through all four formats: + +- a `file` field carrying a **backslash** (`render_escaping.facts.json`): the + `github` render escapes `:` and `,` as `%3A`/`%2C` and leaves the backslash; + SARIF folds `\` to `/` in the `uri`. +- a `file` field carrying **non-ASCII** including an astral character: §1.3. +- a facts **path** through a directory carrying both a space and non-ASCII + (`inputs/pa th ünïcødé/facts.json`): accepted, and the path is echoed + verbatim in the `ok`/summary lines. + +**The Windows reference was measured, and it diverges (§5.3).** This +environment is Linux-only, so the measurement was taken on `windows-latest` +through a temporary CI workflow and is recorded in §1.9. The divergence is far +wider than the non-ASCII cases alone. The fixture stays Linux-generated and +nothing about the Windows reference is normalized away; §5.3 states the result +as three separate claims, of which **native-Windows Python parity is claim C and +is NOT claimed**. + +### 1.7 SIGINT — measured, **not pinned** + +A synthesized 1.7 MB facts document (400 components × 40 subscriptions, ~1.3 s +of work; not committed) interrupted mid-run on Linux, five trials: + +| subject | exit status | stdout | stderr | +|---|---|---|---| +| `python -m ownlang ownir … --format sarif` | killed by **SIGINT** (signal 2) every trial | 0 bytes | a full `KeyboardInterrupt` traceback | +| a throwaway Rust binary with no signal handler | killed by **SIGINT** (signal 2) every trial | 0 bytes | empty | + +`KeyboardInterrupt` is a `BaseException`, so `run()`'s `except Exception` +catch-all never sees it: the reference dies by the signal rather than exiting +70. A plain Rust binary installs no handler and dies the same way, so the two +agree on the **exit disposition** on Linux without anything being written down. + +The Windows measurement is in §1.9, and it is a *different* answer. + +**Not pinned, and the reason:** the reference's stderr on Linux is a traceback +whose text **changes between trials** (it names whichever line the interrupt +landed on), and the two platforms do not agree on the exit status either. The +fixture writer refuses a non-deterministic case by construction, and pinning +only half of a case would be pinning less than was measured. `130` is not +invented anywhere, and neither is a cross-platform interruption contract. + +### 1.8 Closed stdout — measured, deterministic, **not expressible as a case** + +`… --format sarif | head -c 1`, three trials, identical every time: + +```text +exit 70 +ownlang: internal error: BrokenPipeError: [Errno 32] Broken pipe + This is a bug in the analyzer, not in your code. Re-run with OWNLANG_DEBUG=1 for the full traceback and please report it. +``` + +So a write failure on stdout is the **internal-error path**, not a usage error +and not a silent success. The binary matches that *shape* — exit 70 with its own +two-line internal-error diagnostic — and is unit-covered for it. It is not a +fixture case because the case format (`argv`/`cwd`/`env` → captured streams) has +no way to close the consumer's pipe; recording it here is the alternative to +pretending it was frozen. + +### 1.9 The Windows reference, measured + +Taken on `windows-latest` (Windows 10.0.26100, CPython 3.11.9) through a +temporary CI workflow, because this implementing environment is Linux-only and +#261 rules that interruption is measured on both platforms **before** anything +is written down. The run: +[actions/runs/34198702579](https://github.com/PhysShell/Own.NET/actions/runs/34198702579). +The workflow and its script were deleted once these numbers landed here; the +commit that added them is the record that the measurement was taken. + +`sys.stdout.encoding` on a piped Windows stdout is **`cp1252`**, and that one +fact drives most of what follows. + +| case | Linux reference | Windows reference | +|---|---|---| +| non-ASCII `file`, `human` | exit 1, UTF-8 bytes | **exit 70** — `UnicodeEncodeError: 'charmap' codec can't encode characters in position 12-14: character maps to ` | +| non-ASCII `file`, `github` | exit 1, UTF-8 bytes | **exit 70**, the same encoder failure | +| non-ASCII `file`, `sarif` | exit 1, pure ASCII | exit 1, pure ASCII — the ASCII escaping saves this one | +| the em dash in the `ok` line | `e2 80 94` (UTF-8) | **`97`** (cp1252) | +| every line ending | `\n` | **`\r\n`** (text-mode `print` translates) | +| a missing file | `[Errno 2] No such file or directory` | the same | +| a directory | `[Errno 21] Is a directory` | `[Errno 13] Permission denied` | +| closed stdout | exit 70, `BrokenPipeError`, 3/3 trials | **the same**, 3/3 trials | +| interruption | killed by **SIGINT** (signal 2); stderr a `KeyboardInterrupt` traceback whose text varies per trial | **`0xC000013A`** (`STATUS_CONTROL_C_EXIT`, 3221225786); stdout 0 bytes; **stderr empty**, 3/3 trials | + +Two of these are worth naming out loud: + +* **the em dash is in almost every case.** It is in the `ok` line and in most + finding messages, so the CRLF/cp1252 pair means the Windows reference + produces different bytes for nearly every case in the fixture, not only the + ones with non-ASCII inputs; +* **interruption is deterministic on Windows and not on Linux.** Windows gives + a fixed status and an empty stderr; Linux gives a fixed *disposition* and a + varying traceback. Neither is pinned, because a contract that held on one + platform and not the other would not be a contract. + +--- + +## §2 — The fixture format, the boundary as applied, and the churn budget + +### 2.1 The format + +`tests/fixtures/cli_ownir/` holds `manifest.json` plus one `.case.json` +per case. + +```jsonc +// manifest.json +{"comment": "...", "cli_ownir_version": 1, + "shell_usage": "", + "ownir_usage": "", + "cases": [{"name": "...", "oracle": "python|python-docstring|owen-convention", + "rules": ["exit-independent-of-severity", ...], + "pins": ["..."]}]} +``` + +```jsonc +// .case.json +{"cli_ownir_version": 1, + "argv": ["ownir", "inputs/…"], // relative to the fixture dir + "cwd": ".", // relative to the fixture dir + "env": {"OWNLANG_DEBUG": "1"} | {}, + "expected": {"exit": 2, "stdout": "…", "stderr": "…", + "os_error_tail": {"linux": "…", "windows": "…"} | null}} +``` + +Streams are **JSON strings** (`\n` escaped), never raw text files: a checkout +with `core.autocrlf` on must not be able to corrupt an expectation. That is the +#343 lesson applied to this family, and it is why no `.txt` expectation exists +anywhere under it. + +### 2.2 The oracle classes, so a reader can tell them apart + +| class | what authored the bytes | cases | +|---|---|---| +| `python` | an executed `python -m ownlang ownir` run | everything after `ownir` except the four below and the declared defect | +| `python-docstring` | the same, and the bytes happen to be the **whole module docstring on stdout** — flagged so the owner can declare the class a defect | §1.1 | +| `owen-convention` | **no Python oracle exists**: authored once from the checked-in help text in the manifest, following the public `owen` convention (`Program.cs` 14–43) | the shell's empty invocation, `--help`, `-h`, `--version`, unknown command; and `ownir --help`, the one declared defect | + +### 2.3 The C-1 boundary as applied + +The boundary is the **surface**, never the reference's internal print branch: + +```text +own-cli -> the shell help on stdout, exit 2 (owen convention) +own-cli --help | -h -> the shell help on stdout, exit 0 (owen convention) +own-cli --version -> "own-cli " on stdout, exit 0 (owen convention) +own-cli -> one error line + help on stderr, exit 2 (owen convention) +own-cli ownir --help -> the ownir usage on stdout, exit 0 (declared defect, not ported) +own-cli ownir -> exactly what the reference does (measured) +``` + +The shell help text has no Python byte oracle. It is written **once**, lives in +the manifest, and the binary and the fixture share that one source — a test +fails if they drift. + +### 2.4 The churn budget, written before anything moved + +New files only: + +- `rust/crates/own-cli/**` — the crate, its binary, its tests; +- `tests/fixtures/cli_ownir/**` — the manifest, the cases, the four new inputs; +- `tests/test_cli_ownir_fixtures.py` — the writer/verifier; +- `docs/evidence/p022-cli-1.json` — the mutation campaign; +- `docs/generated/p022-cli-census.md`, `docs/generated/p022-cli-mutations.md`; +- this note. + +Named existing surfaces, edited and nothing else: + +- `rust/Cargo.toml` — the workspace member list, and the **stale `panic` + design note** (§6); +- `rust/crates/own-diagnostics/tests/dag.rs` — `own-cli`'s edges; +- `rust/README.md` — the `own-cli` row; +- `docs/proposals/P-022-rust-core-migration.md` — row 7b and the `panic` mirror; +- `docs/proposals/README.md` — the index row; +- `.github/workflows/ci.yml` — one new job; +- `scripts/render_checkpoint_status.py`, `tests/test_checkpoint_status.py` — + the campaign registration; +- `scripts/mutate_campaign.py` — one line and its docstring: the catcher + validator special-cased `src/lib.rs` and not `src/main.rs`, and `own-cli` is + the first binary crate whose unit tests a campaign names (§6); +- `docs/generated/p022-coord-census.md` — **regenerated, not edited**: it sweeps + `tests/fixtures/**` for OwnIR documents and the four new inputs are four more + of them. + +**No existing fixture moves, and nothing under `ownlang/` is touched.** + +--- + +## §3 — What landed + +`rust/crates/own-cli/` — a binary crate with one subcommand, and the shape +#345's commands can join rather than a special case they would have to rewrite: +one dispatch table, one help surface. + +* **the shell** answers the C-1 convention: an empty invocation is help on + stdout at exit 2, `--help`/`-h` the same help at exit 0, `--version` is + `own-cli `, and an unknown command is one error line plus + the help on **stderr** at exit 2. The empty invocation and the unknown command + are distinct cases because `owen` makes them distinct; +* **`ownir`** takes the reference's argv — one positional, `--format`, + `--severity`, `--verbosity`, both the `--flag V` and `--flag=V` spellings, no + `--` separator, no short flags — and answers every usage error exactly as the + reference does, the docstring on stdout included; +* **the display policy** is a pure function of `&[Finding]` and the three + options, so every trap is a unit test rather than a process invocation. It + lives in the CLI crate because it is CLI logic: which findings are shown, + the `ok` and summary lines, the verbosity variants, the stream split, and + `1 if leaks else 0`; +* **the renders are reused, never re-derived.** `own_bridge::check_facts` is the + analysis; `own_bridge::render_finding` and `own_bridge::build_sarif` are the + only renderers called. What the CLI adds is the *serialization*, because + `cmd_ownir` writes the SARIF document `json.dumps(indent=2)` does — ASCII + escaped — and the BR-V9 goldens are `ensure_ascii=False`. The builder is not + touched; +* **the process contract** under `panic = "unwind"`: a hook installed first + thing in `main` 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. The hook alone would only *observe* the panic and the + process would still exit 101. Under `OWNLANG_DEBUG` the payload and a captured + backtrace print and the exit is still 70; +* **an off-by-default `fault-injection` feature** gates two dev-only hooks that + force each failure mode, so both rulings are measured rather than asserted. + No production build carries them. + +The DAG gains exactly two edges — `own-cli -> own-ir` and `own-cli -> +own-bridge` — registered in `own-diagnostics/tests/dag.rs`, where an +unregistered member fails the whole `cargo test`. No `own-codegen`, no +`own-shadow`, no `sha2`. + +**Nothing is wired.** `owen`, `own-check.sh`, `own-check.ps1`, `action.yml`, +`own-shadow-engine` and `scripts/shadow_compare.py` are untouched; the binary +knows nothing of Python and offers no engine selection, no compare mode and no +fallback. Python remains the public engine. + +## §4 — The ledger, and where the numbers are + +* the fixture cases by rule and by oracle class: + [`docs/generated/p022-cli-census.md`](../generated/p022-cli-census.md); +* the mutation campaign: + [`docs/generated/p022-cli-mutations.md`](../generated/p022-cli-mutations.md), + from [`docs/evidence/p022-cli-1.json`](../evidence/p022-cli-1.json) and its + recorded run. + +The campaign runs **three layers** rather than a workspace sweep, because the +two failure-mode controls live behind the off-by-default feature and a campaign +that cannot run the layer holding a catcher cannot see it catch. The third +layer runs every *other* workspace member and is expected to catch nothing: a +catcher appearing there would mean a mutation reached past its target. + +Two of the campaign's mutations exist as a pair because the first run said so. +`usage_error` and the docstring answer are two different paths to exit 2, and a +catcher named on one cannot see a mutation in the other. The wrong expectation +was corrected **and** the path it had named became its own mutation, rather than +the expectation being quietly dropped. + +## §5 — What was fixed, what is declared, and what is not claimed + +### 5.1 Invalid UTF-8 — a declared **reference defect** (#261 ruling 1) + +`load()` converts `OSError` and `JSONDecodeError` and nothing else, so a +`UnicodeDecodeError` escapes to `run()`'s catch-all: + +```text +exit 70 +ownlang: internal error: UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 45: invalid start byte + This is a bug in the analyzer, not in your code. Re-run with OWNLANG_DEBUG=1 for the full traceback and please report it. +``` + +**Status: a declared defect of the Python reference, excluded from #261's byte +contract pending a Python-first normalization before public cutover.** A debt +with a name and an owner, not a permanent constitutional exclusion. + +The binary's `rc 70` + internal-error shape is sufficient for #261, and there is +no byte fixture. The reason is worth stating precisely, because an earlier draft +of this note got it wrong: it is **not** that no oracle exists — Python printed +one, and it is quoted above. It is that we **decline to make a CPython +exception's wording a cross-language contract**. Freezing that line would bind +the Rust implementation to the spelling of a `UnicodeDecodeError`, which is a +CPython implementation detail that no part of this project has decided to own. + +**Python-first hygiene tail.** `UnicodeDecodeError` → `OwnIRError` → `rc 2`, so +a file that is not UTF-8 is refused by the strict door like every other +malformed input instead of crashing the run. To close **before public cutover**; +#261 does not block on it. + +**Tracker state, as it actually stands.** #262 is the tracker of record: the +reviewer recorded this ruling there under *Known differences recorded ahead of +the packet* (2026-09-08). The #250 roadmap mirror is **pending reconciliation** +— queued, not yet written — so this note claims #262 and nothing more. An +earlier draft said the tail was "opened under #250/#262", which asserted a +tracker state this note is not the authority on and had not verified. + +### 5.2 The strict door: one family **fixed**, one narrow boundary **declared** + +The 1a32185 draft of this note lumped these together as "strict-door messages +differ". They are not one thing, and the review was right to split them. + +#### 5.2a The Version family — FIXED to byte parity (#261 ruling 2a) + +The `ownir_version` rejection text is **ours on both sides**, so a divergence +there was a Rust bug rather than a boundary. It is fixed. + +**The taxonomy first**, because the corrected one has two rejection rows and not +three — an absent `ownir_version` is *accepted as v0* and carries no message, so +it is no control to reproduce: + +```text +absent -> accepted as v0 (no message) +present, valid v0 -> accepted (no message) +present, wrong type -> Version rejection, repr-sensitive message +present, integer != v0 -> Version rejection, schema-mismatch message +``` + +**The census**, over both rejection rows and their value variants, measured on +this tree before anything was edited: + +| control | Python | Rust *before* | Rust *after* | +|---|---|---|---| +| `ownir_version` absent | accept | accept | accept — same, no message | +| `= 0` | accept | accept | accept — same, no message | +| `= "0"` | `got '0'` | `got "0"` ✗ | `got '0'` ✓ | +| `= "x"` | `got 'x'` | `got "x"` ✗ | `got 'x'` ✓ | +| `= true` | `got True` | `got true` ✗ | `got True` ✓ | +| `= false` | `got False` | `got false` ✗ | `got False` ✓ | +| `= 0.0` | `got 0.0` | `got 0.0` ✓ | `got 0.0` ✓ | +| `= null` | `got None` | `got null` ✗ | `got None` ✓ | +| `= [1]` | `got [1]` | `got [1]` ✓ | `got [1]` ✓ | +| `= 99` / `1` / `-3` | `… Roslyn extractor … Python core …` | `… extractor … core …` ✗ | matches ✓ | + +Two things the ruling's own list did not enumerate, found by taking the census +rather than trusting it: **`null` → `None`** is a third wrong-type divergence, +and **containers** diverge as well (`["a"]` is `['a']` to Python), because +`json` decodes them to `list`/`dict` and `repr` reprs their members. + +**The layer, and why.** A pre-change consumer/churn census decided it, not +preference. `own-ir` states that its cross-language contract on this surface is +`OwnIrErrorKind` with the message a human-facing aid, so editing the text is a +deeper layer than #261 needs — unless nothing frozen depends on it: + +| consumer of the old Version text | class | churn expected | +|---|---|---| +| `own-ir/src/strict.rs` (both messages) | production implementation | **the edit itself** | +| `own-ir/tests/roundtrip.rs` | unit test — asserts `contains("schema v1")` and `contains("v0")` | none: both substrings survive | +| `own-ir/tests/validation_replay.rs` | unit test — `contains(needle)` over a hardcoded list carrying **no** Version needle | none | +| `tests/fixtures/ownir_validation.json` | frozen ledger — **Python-authored**, carries **Python's** messages; the Rust replay compares KIND, never message | none: Rust never writes it | +| #260 repro/shadow evidence (`docs/evidence/`) | — | **none: no artifact carries any door refusal text** | +| generated docs | — | none | +| `own-cli` | production output | the point of the repair | + +So the old text reached only `own-ir`'s implementation, two `own-ir` tests whose +assertions survive, and #261's own new CLI fixtures. Under R2's own rule that +sanctions the `own-ir` edit — and **measured after the change, zero `own-ir` +tests needed to move and no golden was regenerated.** Had the census found a +repro/shadow surface, the alignment would have gone into an `own-cli` +presentation adapter instead, so #260's evidence did not ride along behind #261. + +Kind parity is untouched: #259 compares the kind, so cp1/validation stays green. +Every Version control is now a **pinned** CLI fixture case, `oracle: "python"`. + +`py_repr_value` is a **third** copy of a helper `own-syntax` and `own-cli` +already carry — `own-ir` is the DAG leaf and may import neither. That is the +`own-pyparity` tail getting more expensive, recorded in §6. + +##### The census above was too narrow, and here is what it missed (#261 R2b) + +Everything in it is still true. It is also the wrong shape of measurement, and +the second review was right to say so. Each row picked a *value*; the oracle is +about a *value class*, and the four scalars chosen happen to be the classes +where a `serde_json::Value` and CPython's `json` agree. A `Value` is a **lossy** +rendering of the document for `repr` purposes, and three defects were hiding in +the loss: + +| what `Value` loses | `Value` says | CPython says | why the first census could not see it | +|---|---|---|---| +| object key **order** (`Map` is a `BTreeMap`; `dict` is insertion-ordered) | `{'a': 2, 'b': 1}` | `{'b': 1, 'a': 2}` | its only object control had **one key**, and one key is order-invariant | +| float **spelling** (`Display` writes ryū's shortest form; `repr` pads the exponent) | `1e-6` | `1e-06` | its only float was `0.0`, which needs no exponent | +| integer **precision** (no `arbitrary_precision`, so an oversized literal arrives as `f64`) | `1e+31`, wrong-type arm | `100000…000`, **mismatch** arm | its integers all fit `i64` | + +The third is not a spelling difference at all: a Python `int` has no width, so +an oversized *integral* version clears the reference's type check and takes its +**mismatch** branch (#261 ruling V3). Same document, different arm. + +**The fix is a raw re-read, not a feature flag.** `own-ir` now reads +`json.loads(raw)["ownir_version"]` back out of the document text into a +`PyValue` — insertion-ordered dicts with CPython's rebind-in-place semantics for +a repeated key, arbitrary-precision integers kept as digits, and `int`/`float` +decided by the literal. `serde_json/preserve_order` and `arbitrary_precision` +would each fix a row by changing `Value` for **every crate in the workspace** +— map iteration order, number equality, `to_value` output — and #260's +canonical-domain evidence is measured against the current `Value`. So the +re-read is scoped to the one value whose spelling is contractual and runs on the +rejection path only. `serde_json` stays the only parser whose verdict decides +accept/reject; the re-read decides only how an already-certain rejection is +spelled. + +**Then the sweep found a fourth.** 200 000 doubles through the production path +against CPython's `repr`: 7 mismatches, all the same shape. Where a double's +exact value sits **exactly midway** between two candidates of the shortest +round-trip length, CPython (David Gay's `dtoa`) rounds half to **even** and +Rust's shortest formatter does not — `-1128910513108089.25` is +`-1128910513108089.2` there and `...3` here, about one double in 3 000. The +digits now come from Rust's *exact* formatter asked for the shortest formatter's +length, which rounds half to even. Re-swept: **200 000 / 0**. + +**The re-measurement, in two groups.** The groups are separate so the declared +divergences can never drift back into the byte denominator: + +*Group 1 — the byte denominator.* 24 value classes, **24/24 byte-identical**: +the five float classes either side of both presentation boundaries, the +round-half-to-even tie, out-of-order and repeated-key and nested and empty +objects, quote-heavy and astral and plain strings, both bools, `null`, arrays, +oversized integers of both signs, the `i64` boundary, and an ordinary mismatch. +Then **20 000 randomized documents** — nested containers, repeated keys, the +whole string surface, oversized integers — **0 mismatches**. + +*Group 2 — declared, and NOT in that denominator.* + +| ruling | class | reference | this core | why it is declared | +|---|---|---|---|---| +| V1 | `NaN`, `Infinity`, `-Infinity` | `got nan` / `inf` / `-inf` | `not valid JSON` | CPython `json` extensions, not JSON. Teaching the Rust parser non-standard JSON to match an error message would widen what the product accepts | +| V2 | literal `-0` at the gate | **accepts** (an `int`) | `got -0.0` | the cross-parser encoding split #260 already froze. Emulating the acceptance is out of #261's scope, and reporting `0` would name a type we did not read and imply an acceptance we do not grant | +| V4 | Unicode table skew (see below) | escaped | printed | **new**, below | + +`-0` *inside a container* is not V2 and is not declared: nothing there is being +type-checked, so the reference's own spelling is free to be reproduced, and is +(`[-0]` → `[0]` on both sides). That is why the stand-down is written as "the +value the type check is about" rather than "any `-0` anywhere". + +**V4, found by this pass: the Unicode tables are independently versioned.** +`str.isprintable()` is a general-category question, and each side answers it +from the Unicode table it was **built** with. CPython 3.11.15 links Unicode +14.0.0; `unicode-properties` 0.1.4 ships 17.0.0. A whole-plane sweep — all +1 114 112 scalar values, both sides — puts the disagreement at exactly **15 097 +code points**, every one of them `Cn` (unassigned) in 14.0.0 and assigned since. +No single static Unicode-property table can be byte-identical to every supported +CPython reference version on the code points whose classification differs +between them, so a pin is a reference-contract choice rather than a fix: the +reference's table is a property of the *interpreter build*, and a pin would buy +parity with one Python and silently lose it against another. Declared, measured, and out of +the denominator. It predates this pass — `is_printable` shipped in R2 — and it +has the same root cause as the other three: a control set chosen by hand. +`own-syntax` carries the same helper and therefore the same boundary; that is a +tail (§6), not a change #261 makes. + +Its practical reach here is **nil, and measured rather than assumed**: no file in +the frozen CLI fixture family contains one of those code points, which is +why the `oracle: "python"` cases stay green on 3.11, 3.12 and 3.13 even though +those interpreters link three different Unicode tables. The boundary is real; it +is simply not something any case in this contract can reach. + +**The census is now a test, not a table.** `own-ir/tests/version_repr_census.rs` +carries every group-1 row with the byte CPython produced for it, and group 2 +pinned to what this core does with the reference's own answer written beside +each. Python authored those bytes once; the suite defends them with **zero +Python** from here on, and a new value class has to be added to the table rather +than merely not thought of. Campaign mutations M20–M23 attack the four defects +directly. + +#### 5.2b CLI-B1 — the JSON parser detail, a named typed boundary (#261 ruling 2b) + +```text +CLI-B1 JSON_PARSER_DETAIL (applies iff OwnIrErrorKind == Json) + pinned: exit = 2 · stream = stderr · rejection kind = Json · + the FULL CLI-owned wrapper, byte-exact: + "{path}: error: {path} is not valid JSON: " + declared: only the bytes AFTER that exact prefix — the parser library's own + text (CPython "Expecting value: line 1 column 1 (char 0)" + serde_json "EOF while parsing a value at line 1 column 0") +``` + +It has a **name and a kind guard** on purpose: "the JSON parser's detail" cannot +quietly grow into "strict-door wording may differ". No `JSONDecodeError` +emulator was built. + +**The wrapper was drifting, and that is fixed first.** The reference bakes the +path into the message inside `load()` and prints it again in `cmd_ownir`, so the +line carries the path **twice**; `own-ir` emitted it once, because +`from_json(&str)` has no path to bake. Relaxing everything after +`{path}: error: ` would have silently declared that missing half +implementation-defined too — far too much. An `own-cli` presentation adapter +guarded by `kind == Json` supplies the CLI-owned half, touching neither #259 nor +#260. + +**The guard has a lock in it.** A `Json` rejection whose message does not start +with `own-ir`'s own `not valid JSON: ` prefix is a **broken internal invariant +of the Rust implementation**, not a JSON rejection to pass through: it takes the +internal-error path — `rc 70`, one actionable diagnostic — and **CLI-B1 does not +apply**. Never `rc 2`, never the whole message swallowed as the tail. A guard on +the kind that then let the adapter eat its own structural drift would be a door +built with the lock left out. + +**It is an executable guard, not a substring convention.** There is no second +placeholder: one strictly-bounded `` is the entire budget, and a +second would turn the fixture format into a little language of excuses. The case +carries structured metadata — `"boundary": {"id": "CLI-B1", "expected_kind": +"json"}` — and `replay.rs` proves eligibility *before* relaxing anything: + +```text +1. take the facts bytes the CLI case ran with +2. decode with str::from_utf8, NO normalization +3. the decode must SUCCEED — invalid UTF-8 is §5.1's defect alone, never CLI-B1 +4. OwnIr::from_json(that &str) must REJECT +5. the rejection kind must be Json +6. only then: exit == 2, stdout == "", stderr starts with the pinned prefix, + and only the bytes after it are relaxed (and must be non-empty) +``` + +**The negative control, and the hole it had (#261 R3b).** The first version of +it read a *different case at a different path* from the positives it was +contrasted against, and its own comment had quietly relaxed "same path" to "same +path **shape**". That is the one thing a negative control exists to rule out: a +guard keyed on the path, the extension or the case name would have passed it +without ever consulting a rejection kind. The relaxation in the comment is the +tell — a control whose own prose has to widen to stay true is not measuring what +it says. + +The decision now takes the facts bytes as a **parameter**, so the control runs +**one case** — one argv, one exact path string, one decode route, one fixture — +twice, against two byte sequences: + +| run | bytes | strict door | eligible | +|---|---|---|---| +| positive | the case's own facts, on disk | `Json` | yes | +| negative | a valid document with a version mismatch | `Version` | **no** | + +Everything a guard could accidentally be keyed on is held literally identical +across the two runs, and the test asserts that both runs resolve the same path +from the same case, so eligibility can only turn on the content. The bytes are +still frozen fixture bytes rather than bytes invented in the replay — inventing +them would move the oracle into the test. It is still deliberately **not** a +malformed mutation: malformed bytes could be refused by some other parser or +decoder fork and "prove" the guard by accident. The case those negative bytes +come from is itself pinned byte-exact as a Version rejection per §5.2a, and the +control asserts it carries no boundary metadata. + +A sweep test additionally asserts that *every* case carrying CLI-B1 metadata +really does reject with `Json`, so a future case cannot acquire the relaxed +matcher by accident. + +One honest limit: **a control's non-vacuity is a property of its construction, +not something a mutation can prove.** No source mutation distinguishes "this +control compares two documents" from "this control compares one document with +itself" — running the suite cannot tell you a test asserted nothing. What M24 +does show is that the construction is load-bearing: make the guard reach for a +file instead of judging the bytes it was handed, and the control fires. + +### 5.3 Windows — three separate claims (#261 ruling 3) + +The 1a32185 draft ran these together and reached for "the useful half", which +read as a parity flavour over a result that is not parity. They are three +claims, and the third is **not claimed**. + +**A. Canonical reference parity — CLAIMED.** The Rust binary reproduces the +Linux/UTF-8 Python reference byte for byte over every pinned fixture case. + +**B. Rust portability — CLAIMED.** Rust on Linux and Rust on Windows emit the +same bytes: the Linux-authored fixture replays byte-for-byte on +`windows-latest`, including the cases the Windows *reference* cannot produce. +The binary writes through a locked handle with no encoding layer and no +line-ending translation. The unicode-and-space directory name round-trips +through a fresh Windows clone. + +**C. Native-Windows Python parity — NOT CLAIMED.** It is a measured defect of +the reference, and no wording in this note or on any status surface should +suggest otherwise: + +| | Linux reference | Windows reference | +|---|---|---| +| piped `sys.stdout.encoding` | `utf-8` | **`cp1252`** | +| line endings | `\n` | **`\r\n`** | +| the em dash in the `ok` line and most messages | `e2 80 94` | **`97`** | +| non-ASCII `file`, `human` / `github` | exit 1, a finding | **exit 70**, `UnicodeEncodeError` | + +Because the em dash appears in the `ok` line and in most finding messages, the +Windows reference produces different bytes for nearly every case in the fixture, +not only the ones with non-ASCII inputs. §1.9 has the full measurement. + +**A behavior change, not parity — recorded in #262.** Windows Python today +emits cp1252 / CRLF and can fail outright with `UnicodeEncodeError`; Rust +tomorrow emits UTF-8 canonical bytes on both platforms. That is very likely an +improvement — and it is still a **behavior change** for a Windows user whose +tooling consumes those bytes, so it belongs in *Known differences* rather than +being folded into a parity claim. + +#262 is the tracker of record and carries it: the reviewer recorded it there +under *Known differences recorded ahead of the packet* (2026-09-08). The #250 +roadmap mirror is **pending reconciliation**. + +### 5.4 Recorded, deterministic, and not expressible as a case + +* **closed stdout** — exit 70 with the internal-error shape, 3/3 trials on both + platforms. Not a fixture case because the case format (`argv`/`cwd`/`env` → + captured streams) has no way to close the consumer's pipe; the binary matches + the shape and is unit-covered for it. +* **interruption** — §1.7 and §1.9. Measured on both platforms, pinned on + neither, for the reasons given there. +* **the uncatchable death** — `std::process::abort()` on Linux produced shell + status **134** (SIGABRT), zero stdout, zero stderr. The number is recorded + here and deliberately **not** asserted: #261 contracts no OS exit number for + this case. What the test asserts is that the outcome is non-zero, outside + `{0, 1, 2, 70}` and silent on stdout. +* **a non-UTF-8 argv** — `std::env::args_os` is converted lossily rather than + with `std::env::args`, which panics on such an argument. A path that is not + valid Unicode is outside the measured contract (the reference round-trips it + through Python's `surrogateescape`, which has no fixture here) and fails with + an ordinary "cannot read" rather than a crash. Recorded as a known deferred + case. + +## §6 — Tails + +* **the `panic`-per-package design note** is corrected in `rust/Cargo.toml` and + its P-022 mirror: Cargo cannot set `panic` per package — a profile applies to + every target of a build — so "abort for `own-cli`, unwind for the LSP" was + never a plan Cargo could execute. Corrected as a design note; nothing about + the built artifacts moves. +* **`py_repr` is carried THREE times now** — `own-syntax`, `own-cli`, and + (with the repair pass) `own-ir`, whose Version message interpolates one. + Each import is a forbidden edge: `own-cli -> own-syntax` is not in the DAG, + and `own-ir` is the leaf that may depend on no workspace crate at all. A + shared `own-pyparity` leaf is the home; the repair pass raised its price from + "tidy" to "three copies of a Unicode-category table", and #345 — which adds + the commands whose errors interpolate far more `repr()`s — is where it starts + to pay. +* **the `line`-family message still uses `Value`'s Display.** `own-ir`'s + `defaulted_int_value` builds `{what} '{key}' must be an integer, got {other}` + the way the Version message used to, so it carries the same quote/bool/None + divergence. It is a #259 surface and outside #261's scope; the repair pass + deliberately touched only the Version call site. Worth closing with the same + helper when the `own-pyparity` leaf lands. +* **the Unicode table skew is a boundary, not a bug (§5.2a, ruling V4).** + `str.isprintable()` is answered from the table each side was built with, and + the two are independently versioned — the gap is a specific two-version + measurement (CPython 3.11.15 / UCD 14.0.0 vs unicode-properties 0.1.4 / + UCD 17.0.0; see §5.2a), not a fixed size of V4. + `own-syntax` carries the same helper and inherits the same boundary. When the + `own-pyparity` leaf lands it should carry the measurement with it, so the + answer lives in one place rather than being re-derived per crate. +* **the campaign's catcher validator** special-cased `src/lib.rs` and not + `src/main.rs`. `own-cli` is the first binary crate whose unit tests a campaign + names, and without the fix every one of them read as "names a test that does + not exist" while pointing at a test that plainly did. +* **Python-first hygiene** — `UnicodeDecodeError` → `OwnIRError` → `rc 2` in + `ownlang/ownir.py`'s `load()`, so a file that is not UTF-8 is refused by the + strict door like any other malformed input rather than crashing the run. To + close **before public cutover**; #261 does not block on it (§5.1). + **Recorded in #262**, the tracker of record, under *Known differences recorded + ahead of the packet* (2026-09-08); the #250 roadmap mirror is **pending + reconciliation**. +* **Windows byte differences** — Windows Python emits cp1252 / CRLF and can fail + with `UnicodeEncodeError` where Rust emits UTF-8 canonical bytes: an + improvement, and a behavior change (§5.3 claim C). **Recorded in #262** in the + same place; the #250 mirror is **pending reconciliation**. +* **for the owner to decide:** the docstring-on-stdout class (§1.1, four cases, + frozen and flagged) is the one class still awaiting a ruling. +* **for #345 to inherit:** the dispatch table and the single help surface are + built to be joined rather than rewritten; the fixture family's format, its + writer and its oracle classes generalize to any subcommand; and the + `` placeholder rule is already written down. + +## §7 — Reproducing this + +```bash +# regenerate the fixture from the reference (Linux; the authoring platform) +python tests/test_cli_ownir_fixtures.py --write + +# verify the frozen bytes are still the reference's +python tests/test_cli_ownir_fixtures.py + +# the steady-state gate: build the binary and replay, zero Python +cd rust && cargo test -p own-cli + +# the two failure-mode controls (off in every production build) +cd rust && cargo test -p own-cli --features fault-injection --test faults + +# the mutation campaign, on a clean tree +python scripts/mutate_campaign.py --campaign docs/evidence/p022-cli-1.json --run + +# the generated fragments +python scripts/render_checkpoint_status.py --check +``` diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index 3fcedd3a..b210d0be 100644 --- a/docs/proposals/P-022-rust-core-migration.md +++ b/docs/proposals/P-022-rust-core-migration.md @@ -75,13 +75,16 @@ was #258 alone, which is satisfied. Per the checkpoints #259 itself defines: | 6a | OwnIR **bridge semantics formalized** before the port | #258 | **complete** — see above | | 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) | **decision packet ratified, implementation not started.** Owner decisions C-1..C-5 (2026-09-08, recorded verbatim in #261): the oracle is split by surface and behaviour class — `python -m ownlang` for the core semantics of `ownir`, through Python-authored fixtures replayed with zero Python; the public `owen` convention for help/version/usage, whose text becomes a cross-implementation parity surface rather than an invented Python contract, the core's own `--help` reaction being a measured defect whose Python-first repair is hygiene, not a prerequisite; `report` struck, not deferred (#256); the production seam — `ownir --format --severity`, the one core invocation `own-check.*`, the Action and `owen check` make — separated from the residual PoC CLI (`cfg`, `summaries`, `explain`, `.own check`, `emit` behind #257 → #345), which stays a migration obligation but not a gate; engine selection stays outside the executable — one engine, no silent fallback, orchestration in the dev driver now and in the launcher at #262's stages; the production slice alone is #262's CLI prerequisite. Rulings: stdin is not part of the contract; a catchable panic is one actionable stderr line and rc 70; an uncatchable death is a visible hard failure, never findings and never clean; SIGINT is measured on the Windows and Linux reference before anything is contracted. The executable lives **behind** the unchanged `owen` launcher: no default changes, nothing is published. A ratified decision is not completed code — no crate, fixture or CI job exists yet | +| 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 — the owner closes #261 by hand.** 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 — with the #250 roadmap mirror pending reconciliation; (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, with the #250 roadmap mirror pending reconciliation. 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 | blocked by #261 — the production OwnIR executable alone (C-5); #260 reached; #345 is not on this path. #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:** #261 — its decision packet is ratified, the production -OwnIR executable is next — then #262; in parallel and off the critical chain: -#257, #263 (the evidence prerequisite of #262's decision), #345 once #261's -skeleton exists, and #269's reconciliation. cp5, 4b, the coordinate-domain +**Preferred queue:** **#262 is next.** #261's production OwnIR executable is +built and replaying on both platforms (row 7b), so the queue moved past it — +the owner closes #261 by hand after review, and that is a bookkeeping step +rather than remaining work. In parallel and off the critical chain: #257, #263 +(the evidence prerequisite of #262's decision), #345 — the residual `.own`/dev +CLI, which now has the `own-cli` skeleton to join and is **not** on the cutover +path — and #269's reconciliation. cp5, 4b, the coordinate-domain decision and #260's own acceptance decisions are all off this queue: each is complete at its surface. The #260/#269 *infrastructure* slice ran in parallel by design — it is measured on the landed checkpoints and takes no position on the @@ -417,12 +420,19 @@ diagnostic strings — not only transfer-function microbenches. Algorithms befor micro-opt. `#[inline]` only on tiny cross-crate hot functions (`next`/`deref` shape), **never `#[inline(always)]` by reflex**; `#[cold]` on the diagnostic-construction paths is a good, *stable*-Rust hint (`likely`/`unlikely` are -not stable); LTO for cross-crate inlining. **`panic = "abort"` is a per-binary -choice, not doctrine:** fine for `own-cli`; **fatal for a long-lived LSP server**, -where one panic in one file's analysis kills the whole session — and `salsa` -implements *cancellation via unwinding*, so an LSP binary needs `panic = "unwind"` + -a catch at the request boundary (rust-analyzer's model). Record it per profile now so -"abort in release" doesn't get baked into the workspace. Bounds-check elision the +not stable); LTO for cross-crate inlining. **`panic = "abort"` is not a +per-binary choice, and this proposal used to say it was** (corrected with #261): a +cargo profile applies to EVERY target of a build, so there is no per-package +`panic` setting and a `panic = "abort"` written in `[profile.release]` would abort +the whole workspace. That matters because abort is **fatal for a long-lived LSP +server**, where one panic in one file's analysis kills the whole session — and +`salsa` implements *cancellation via unwinding*, so an LSP binary needs +`panic = "unwind"` + a catch at the request boundary (rust-analyzer's model). The +workspace therefore keeps the default `unwind`, and a binary that wants an exit +code of its own gets it the way `own-cli` does (#261): a custom panic hook that +suppresses the default output plus a top-level `catch_unwind` that turns the +unwind into one diagnostic and `rc 70`. A hook alone would only observe the panic +— the catch is what produces the code, and it needs unwinding to exist. Bounds-check elision the **safe** way — consolidate the checks into one early `assert!` and let LLVM prove the rest unreachable (Rust's safe iterators already match the C start/end-pointer idiom) — plus SIMD are the *last* @@ -523,8 +533,9 @@ And the release profile, as TOML rather than prose: lto = "thin" codegen-units = 1 opt-level = 3 -# panic is per-binary, NOT set here: "abort" for own-cli, -# "unwind" for any LSP binary (salsa cancels via unwinding) — see Performance. +# panic is NOT set here, and cannot be set per binary: a cargo profile applies +# to EVERY target of a build. The workspace keeps the default "unwind" — see +# Performance, and #261 for how own-cli reaches rc 70 under it. ``` Do **not** take `pedantic`/`nursery` wholesale to `deny` — they carry noisy lints; diff --git a/docs/proposals/README.md b/docs/proposals/README.md index bc694574..be49b98c 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 decision packet ratified, implementation not started** — #261 is the production Rust OwnIR executable behind the unchanged `owen` launcher (owner decisions C-1..C-5, recorded in #261: the oracle split by surface and behaviour class, `report` struck, the residual `.own`/dev CLI split to #345, engine selection kept outside the executable, the production seam alone as #262's CLI prerequisite); step 8 (#262) is blocked by #261 alone, 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 — with the #250 roadmap mirror pending reconciliation; (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, with the #250 roadmap mirror pending reconciliation. 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 is wired, published or defaulted — that is #262, and the owner closes #261; step 8 (#262) is blocked by #261 alone, 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/rust/Cargo.lock b/rust/Cargo.lock index b251e97c..d8adbb1d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -107,6 +107,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "own-cli" +version = "0.1.0" +dependencies = [ + "own-bridge", + "own-ir", + "serde_json", + "unicode-properties", +] + [[package]] name = "own-diagnostics" version = "0.1.0" @@ -121,6 +131,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "unicode-properties", ] [[package]] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 55edfa18..4433d486 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,7 +8,7 @@ [workspace] resolver = "2" -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"] +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"] [workspace.package] edition = "2021" @@ -51,8 +51,20 @@ panic = "deny" dbg_macro = "deny" print_stdout = "deny" -# Release profile per P-022 — panic is per-binary, NOT set here: "abort" for -# own-cli, "unwind" for any LSP binary (salsa cancels via unwinding). +# Release profile per P-022. `panic` is deliberately NOT set here, and the note +# that used to stand in this place — "panic is per-binary: abort for own-cli, +# unwind for any LSP binary" — was a stale design note rather than a plan Cargo +# can execute: a profile applies to EVERY target of a build, so there is no +# per-package `panic` and a `panic = "abort"` written here would abort the whole +# workspace, an LSP binary included. +# +# The workspace therefore keeps the default `unwind`, and #261 rules that this +# is the contract build for `own-cli`: a custom panic hook suppresses the +# default output and a top-level `catch_unwind` turns an ordinary panic into one +# actionable stderr diagnostic and exit 70 (never 101). A hook alone would only +# OBSERVE the panic; the catch is what produces the exit code, and it needs +# unwinding to exist. Corrected here and in its P-022 mirror as a design note, +# not as a behaviour change: nothing about the built artifacts moves. [profile.release] lto = "thin" codegen-units = 1 diff --git a/rust/README.md b/rust/README.md index 0aa58b2f..600312bd 100644 --- a/rust/README.md +++ b/rust/README.md @@ -29,7 +29,7 @@ implementation-status block; this table is the one-line orientation: | `own-lowered` | **done** (#259) | The typed Layer 2 document + canonical emitter the bridge lowers into. | | `own-bridge` | **done** (#259 final acceptance reached, PR #341; the exclusion ledger names only the two #294 OD-1 door controls, a declared boundary) | The OwnIR bridge: facts → Layer 2 → core AST → analyses → verdicts (`lower`, `dump_summaries`, `check_facts`). | | `own-codegen` | not started (#257) | C# emission (`emit_*` templates), verdict-independent. | -| `own-cli` | not started (#261 — decision packet ratified 2026-09-08; no code yet) | The entry-point binary. #261 builds it as the production OwnIR executable — `ownir` behind the existing `owen` launcher, one engine, no engine selection; the residual `.own`/dev subcommands (`cfg`, `summaries`, `explain`, `check`; `emit` after #257) are #345 and join the same binary. `own-oracle` is the dev-only differential harness alongside it. | +| `own-cli` | built for the `ownir` slice (#261 261.B — the owner closes the issue) | The entry-point binary: `own-cli ownir [--format F] [--severity S] [--verbosity V]`, the one core invocation the product seam makes today, reproducing the reference over a frozen fixture replayed with **zero Python** on Linux and Windows CI. One engine, no engine selection, no fallback: it knows nothing of Python, and it is not wired to anything — `owen`, `own-check.*` and the Action are untouched, and Python stays the public engine until #262. The top-level shell follows the public `owen` convention as a parity surface of its own; everything after `ownir` is the reference as measured. The strict door's Version messages are byte-parity with the reference, measured over value **classes** rather than sample values — 24/24 classes and 20 000 randomized documents identical, with three exceptions named and excluded rather than counted (the reference's non-standard JSON constants, the literal `-0`, and the two sides' independently versioned Unicode tables); the JSON parser's own detail is the one declared boundary (CLI-B1), whose CLI-owned wrapper is pinned and whose guard proves `kind == Json` before relaxing anything, on facts bytes supplied to it so its negative control flips on content alone. An off-by-default `fault-injection` feature carries the two forced-failure controls (`cargo test -p own-cli --features fault-injection --test faults`) so the panic and death rulings are measured, not asserted. The residual `.own`/dev subcommands (`cfg`, `summaries`, `explain`, `check`; `emit` after #257) are #345 and join THIS binary. `own-oracle` is the dev-only differential harness alongside it. | ## Build & test diff --git a/rust/crates/own-cli/Cargo.toml b/rust/crates/own-cli/Cargo.toml new file mode 100644 index 00000000..e0fc956e --- /dev/null +++ b/rust/crates/own-cli/Cargo.toml @@ -0,0 +1,52 @@ +# The production OwnIR executable (P-022 step 7b, #261). P-022's crate plan +# names `own-cli` the entry point, and this is it, created with the single +# `ownir` subcommand — the one core invocation the product seam makes today +# (`own-check.sh`, `own-check.ps1`, the Action and `owen check` all run +# `python -m ownlang ownir --format F --severity S`, and nothing else). +# #345 adds `cfg`/`summaries`/`explain`/`.own check`/`emit` to THIS binary +# later, so the dispatch is one table and the help is one surface rather than a +# special case those commands would have to rewrite. +# +# The DAG (own-diagnostics/tests/dag.rs): `own-ir` for the typed strict door and +# `own-bridge` for the analysis and the renders. Nothing else — the CLI holds no +# analysis and no bridge logic, only argument handling, I/O, the display policy +# and the process contract. `own-bridge` may be depended on ONLY by entry-point +# crates, and this is the one P-022 named. +[package] +name = "own-cli" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "own-cli: the production OwnIR executable (`own-cli ownir`), parity with `python -m ownlang ownir`" + +[[bin]] +name = "own-cli" +path = "src/main.rs" + +[dependencies] +own-ir = { path = "../own-ir" } +own-bridge = { path = "../own-bridge" } +# The SARIF log is serialized here rather than in the bridge because the CLI's +# byte shape is NOT the fixture emitter's: `cmd_ownir` prints +# `json.dumps(indent=2)` with `ensure_ascii` left at its default, so the CLI +# path is pure ASCII with `\uXXXX` escapes, while the BR-V9 goldens are +# `ensure_ascii=False`. Two byte shapes, one builder — see src/sarif.rs. +serde_json.workspace = true +# Unicode general categories, the data behind CPython's `str.isprintable()`. +# The reference interpolates `repr()` of a rejected flag value +# (`unknown --format {fmt!r}`), so byte-identical usage errors need CPython's +# quoting rules. Not a workspace-crate edge: `own-syntax` owns the same helper +# but the DAG does not admit `own-cli -> own-syntax` (see src/pyrepr.rs). +unicode-properties.workspace = true + +[features] +# OFF by default and absent from every production build. It gates two dev-only +# hooks that force the two failure modes #261 rules on, so the forced-panic and +# forced-death controls are MEASURED by the integration test rather than +# asserted from the design. See src/faults.rs. +fault-injection = [] + +[lints] +workspace = true diff --git a/rust/crates/own-cli/src/faults.rs b/rust/crates/own-cli/src/faults.rs new file mode 100644 index 00000000..a07a0630 --- /dev/null +++ b/rust/crates/own-cli/src/faults.rs @@ -0,0 +1,48 @@ +//! The two forced-failure controls, behind the off-by-default +//! `fault-injection` cargo feature. +//! +//! #261 rules on two failure modes: +//! +//! ```text +//! catchable panic -> exactly one actionable stderr diagnostic + exit 70, +//! never 101 +//! uncatchable death -> a visible hard failure: non-zero, outside +//! {0, 1, 2, 70}, no findings, no `ok` line, never +//! masked. NO particular OS exit number is contracted. +//! ``` +//! +//! Both are *measured* rather than asserted from the design, which needs a way +//! to make them happen on demand — and a production binary must not carry one. +//! Hence the feature: `cargo build -p own-cli` compiles [`maybe_inject`] to a +//! `const fn` with an empty body, and only +//! `cargo test -p own-cli --features fault-injection` builds the hooks at all. +//! +//! The panic is raised with [`std::panic::panic_any`] rather than the `panic!` +//! macro. That is not a way around the workspace's `clippy::panic` deny — it is +//! the right API for this job: `panic_any` takes the payload as a value, which +//! is exactly what the diagnostic has to recover and print, so the control +//! exercises the payload path a real panic would take instead of a stringly +//! special case. + +/// Force one of the two failure modes when the environment asks for it. +/// +/// Called after argument parsing, so the control travels the same path a real +/// panic in the analysis would: through `catch_unwind` in `main`, with the hook +/// already installed. +#[cfg(feature = "fault-injection")] +pub(crate) fn maybe_inject() { + if std::env::var_os("OWN_CLI_FAULT_PANIC").is_some() { + std::panic::panic_any("forced panic (fault-injection)"); + } + if std::env::var_os("OWN_CLI_FAULT_ABORT").is_some() { + // A genuinely UNCATCHABLE termination: `catch_unwind` cannot see it and + // no hook runs. What the OS then reports (a SIGABRT on Unix, an abort + // status on Windows) is recorded by the note, never contracted. + std::process::abort(); + } +} + +/// The production build: nothing to inject, and nothing compiled in to inject +/// it with. +#[cfg(not(feature = "fault-injection"))] +pub(crate) const fn maybe_inject() {} diff --git a/rust/crates/own-cli/src/main.rs b/rust/crates/own-cli/src/main.rs new file mode 100644 index 00000000..066b880e --- /dev/null +++ b/rust/crates/own-cli/src/main.rs @@ -0,0 +1,319 @@ +//! `own-cli` — the production OwnIR executable (P-022 step 7b, #261). +//! +//! ```text +//! own-cli help -> stdout, exit 2 +//! own-cli --help | -h help -> stdout, exit 0 +//! own-cli --version version -> stdout, exit 0 +//! own-cli one error line + help -> stderr, exit 2 +//! own-cli ownir ... exactly what `python -m ownlang ownir` does +//! ``` +//! +//! ## The oracle boundary (#261 C-1) +//! +//! The two halves above have **different oracles**, and the boundary is the +//! surface rather than the reference's internal print branch. The top-level +//! shell follows the public `owen` convention +//! (`frontend/roslyn/OwnSharp.Cli/Program.cs`) — a new cross-implementation +//! parity surface with no Python byte oracle, written once and frozen by +//! `tests/fixtures/cli_ownir/`. Everything **after `ownir` is selected** 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. +//! +//! ## What it is not +//! +//! It knows nothing of Python (C-4): no engine selection, no compare mode, no +//! fallback, no environment variable that picks an engine. A Rust failure is +//! never a Python success, because there is no Python here to fall back to. +//! `owen`, `own-check.*`, the Action, `own-shadow-engine` and the compare +//! driver are untouched — Python remains the public engine until #262 says +//! otherwise, and nothing in this crate is wired to anything. +//! +//! ## The process contract +//! +//! ```text +//! 0 clean +//! 1 findings — any non-advisory, unsuppressed finding, independent of +//! --severity +//! 2 usage error, or a facts document the strict door refuses +//! 70 internal error — ONE actionable stderr diagnostic, never 101 +//! ``` +//! +//! 70 rather than Rust's default 101 is the whole reason the panic path exists, +//! and a hook alone would not produce it: a hook only *observes* a panic, and +//! the process would still exit 101. The build keeps `panic = "unwind"` (the +//! workspace default — Cargo cannot set `panic` per package, so a profile that +//! aborted here would abort the whole workspace build), the hook suppresses the +//! default panic output and records the payload, and a top-level +//! [`std::panic::catch_unwind`] turns the unwind into the diagnostic and the +//! exit. Under `OWNLANG_DEBUG` the hook prints the payload and a captured +//! backtrace and the exit is **still 70** — the asymmetry the reference's +//! `run()` keeps, because exit 1 would read as findings and `owen check` +//! without `--fail-on-finding` maps that to a clean scan. + +#![allow(clippy::print_stderr)] + +mod faults; +mod ownir; +mod pyrepr; +mod sarif; +mod text; + +use std::io::Write as _; +use std::process::ExitCode; +use std::sync::Mutex; + +/// The second line of the internal-error diagnostic. Named so the panic path +/// and the I/O-failure path cannot drift into two different apologies. +const INTERNAL_ERROR_HINT: &str = " This is a bug in the analyzer, not in your code. \ +Re-run with OWNLANG_DEBUG=1 for the full backtrace and please report it.\n"; + +/// Where the panic hook leaves what it saw, for `catch_unwind` to report. The +/// hook runs *during* the unwind and the catch runs after it, so the payload +/// has to survive the gap between them. +static PANIC_PAYLOAD: Mutex> = Mutex::new(None); + +/// The streams and the exit code of one invocation. Building them as values +/// rather than printing as we go is what lets the display policy be a pure +/// function a unit test can drive without a process. +pub(crate) struct Outcome { + pub(crate) stdout: String, + pub(crate) stderr: String, + pub(crate) exit: u8, +} + +impl Outcome { + pub(crate) const fn new(stdout: String, stderr: String, exit: u8) -> Self { + Self { + stdout, + stderr, + exit, + } + } + + /// The one actionable diagnostic and exit 70. Used by every internal + /// failure that is not a panic — a stdout write that fails, an input this + /// binary cannot decode — so all of them answer in one voice. + pub(crate) fn internal_error(cause: &str) -> Self { + Self::new( + String::new(), + format!("own-cli: internal error: {cause}\n{INTERNAL_ERROR_HINT}"), + 70, + ) + } +} + +fn debug_enabled() -> bool { + // Non-empty, like the reference's `os.environ.get("OWNLANG_DEBUG")` + // truthiness — an exported-but-empty variable does not turn debug on. + std::env::var_os("OWNLANG_DEBUG").is_some_and(|value| !value.is_empty()) +} + +/// Recover a panic's payload the way the standard hook would print it. +/// +/// It takes the payload rather than the hook info on purpose: the info type was +/// renamed (`PanicInfo` -> `PanicHookInfo`) in a release later than the +/// workspace's declared `rust-version`, so naming it here would either warn +/// about the MSRV or use a deprecated alias. The closure passed to `set_hook` +/// infers the type; this function never has to spell it. +fn payload_of(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(text) = payload.downcast_ref::<&str>() { + return (*text).to_owned(); + } + if let Some(text) = payload.downcast_ref::() { + return text.clone(); + } + // A payload of some other type carries nothing printable. The reference's + // equivalent is `type(exc).__name__: exc`; here the honest answer is the + // word itself rather than an invented cause. + "panic".to_owned() +} + +/// Install the hook that makes exit 70 possible: it suppresses the default +/// panic output (which would otherwise print a second, differently-worded +/// diagnostic) and records what it saw. Under `OWNLANG_DEBUG` it also prints +/// the payload and a captured backtrace — the debug half of the reference's +/// asymmetry, where the technical cause is shown and the exit code is kept. +fn install_panic_hook() { + std::panic::set_hook(Box::new(|info| { + let payload = payload_of(info.payload()); + if let Ok(mut slot) = PANIC_PAYLOAD.lock() { + *slot = Some(payload.clone()); + } + if debug_enabled() { + let where_ = info + .location() + .map_or_else(|| "unknown location".to_owned(), ToString::to_string); + eprintln!("own-cli: panic: {payload} (at {where_})"); + eprint!("{}", std::backtrace::Backtrace::force_capture()); + } + })); +} + +/// Write the outcome's streams. A failure here is an internal error rather +/// than a silent truncation — the reference does the same thing (measured: +/// `--format sarif | head -c 1` exits 70 with its internal-error diagnostic, +/// deterministically, because a `BrokenPipeError` reaches its catch-all). +fn emit(outcome: &Outcome) -> std::io::Result<()> { + if !outcome.stdout.is_empty() { + let mut out = std::io::stdout().lock(); + out.write_all(outcome.stdout.as_bytes())?; + out.flush()?; + } + if !outcome.stderr.is_empty() { + let mut err = std::io::stderr().lock(); + err.write_all(outcome.stderr.as_bytes())?; + err.flush()?; + } + Ok(()) +} + +/// The C-1 shell. #345 adds its subcommands to this one table. +fn dispatch(args: &[String]) -> Outcome { + match args.first().map(String::as_str) { + // The empty invocation and the unknown command are DISTINCT cases: the + // first is a bare help on stdout, the second an error line plus the + // help on stderr. `owen` draws that line and so does this. + None => Outcome::new(text::SHELL_USAGE.to_owned(), String::new(), 2), + Some("--help" | "-h") => Outcome::new(text::SHELL_USAGE.to_owned(), String::new(), 0), + Some("--version") => Outcome::new( + format!("own-cli {}\n", env!("CARGO_PKG_VERSION")), + String::new(), + 0, + ), + Some("ownir") => ownir::run(args.get(1..).unwrap_or(&[])), + Some(other) => Outcome::new( + String::new(), + format!( + "own-cli: unknown command {}\n{}", + pyrepr::py_repr(other), + text::SHELL_USAGE + ), + 2, + ), + } +} + +fn run() -> u8 { + // `args_os` rather than `args`: the latter PANICS on an argument that is + // not valid Unicode, and a CLI that takes file paths must not turn a + // user's file name into an internal error. The lossy conversion is a + // deliberate, recorded limit — a path that is not valid Unicode is outside + // the measured contract (the reference round-trips it through Python's + // surrogateescape, which has no fixture here) and fails with an ordinary + // "cannot read" rather than a crash. + let args: Vec = std::env::args_os() + .skip(1) + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + let outcome = dispatch(&args); + match emit(&outcome) { + Ok(()) => outcome.exit, + Err(err) => { + // The streams are already half-written by definition; the exit code + // is the part that still has to be honest. + let failure = Outcome::internal_error(&format!("cannot write output: {err}")); + let _ = emit(&failure); + failure.exit + } + } +} + +fn main() -> ExitCode { + install_panic_hook(); + // The catch is the mechanism, not the hook: without it an ordinary panic + // exits 101, which #262's launcher would read as an unexpected child status + // rather than the internal-error path. + let code = match std::panic::catch_unwind(run) { + Ok(code) => code, + Err(_) => { + if !debug_enabled() { + let payload = PANIC_PAYLOAD + .lock() + .ok() + .and_then(|slot| slot.clone()) + .unwrap_or_else(|| "panic".to_owned()); + let failure = Outcome::internal_error(&payload); + let _ = emit(&failure); + } + // Under OWNLANG_DEBUG the hook has already printed the payload and + // the backtrace; the reference prints the traceback INSTEAD of the + // polite line, not as well as it. The exit is 70 either way. + 70 + } + }; + ExitCode::from(code) +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::{dispatch, text, Outcome}; + + fn argv(args: &[&str]) -> Vec { + args.iter().map(|a| (*a).to_owned()).collect() + } + + /// The four shell cases of C-1, including the one distinction that is easy + /// to lose: an empty invocation is not an unknown command. + #[test] + fn the_shell_follows_the_owen_convention() { + let empty = dispatch(&argv(&[])); + assert_eq!(empty.exit, 2); + assert_eq!(empty.stdout, text::SHELL_USAGE); + assert_eq!(empty.stderr, ""); + + for flag in ["--help", "-h"] { + let help = dispatch(&argv(&[flag])); + assert_eq!(help.exit, 0, "{flag}"); + assert_eq!(help.stdout, text::SHELL_USAGE); + assert_eq!(help.stderr, ""); + } + + let version = dispatch(&argv(&["--version"])); + assert_eq!(version.exit, 0); + assert_eq!( + version.stdout, + format!("own-cli {}\n", env!("CARGO_PKG_VERSION")) + ); + + let unknown = dispatch(&argv(&["bogus"])); + assert_eq!(unknown.exit, 2); + assert_eq!( + unknown.stdout, "", + "an unknown command says nothing on stdout" + ); + assert_eq!( + unknown.stderr, + format!("own-cli: unknown command 'bogus'\n{}", text::SHELL_USAGE) + ); + } + + /// C-1's declared defect: the reference answers `ownir --help` with + /// "cannot read --help" and exit 2. That is not ported. + #[test] + fn ownir_help_answers_with_the_usage_rather_than_the_defect() { + let help = dispatch(&argv(&["ownir", "--help"])); + assert_eq!(help.exit, 0); + assert_eq!(help.stdout, text::OWNIR_USAGE); + assert_eq!(help.stderr, ""); + } + + /// The internal-error surface is one voice: the same two lines whatever + /// produced it, and never an exit a caller could read as findings or clean. + #[test] + fn the_internal_error_surface_is_one_shape() { + let failure = Outcome::internal_error("something specific"); + assert_eq!(failure.exit, 70); + assert_eq!(failure.stdout, ""); + let mut lines = failure.stderr.lines(); + assert_eq!( + lines.next(), + Some("own-cli: internal error: something specific") + ); + assert!(lines + .next() + .is_some_and(|line| line.contains("This is a bug in the analyzer"))); + assert_eq!(lines.next(), None, "exactly one diagnostic, two lines"); + } +} diff --git a/rust/crates/own-cli/src/ownir.rs b/rust/crates/own-cli/src/ownir.rs new file mode 100644 index 00000000..88722000 --- /dev/null +++ b/rust/crates/own-cli/src/ownir.rs @@ -0,0 +1,682 @@ +//! The `ownir` subcommand: argument handling, the strict door, and the +//! reference's display policy. +//! +//! C-1 puts the oracle boundary at the **surface**: once `ownir` is selected, +//! every answer — the usage errors, the refusals, every finding, every summary +//! line, every stream and every exit code — is `python -m ownlang ownir` as +//! measured, and `tests/fixtures/cli_ownir/` is the measurement. +//! +//! Two things this module does NOT do, and they are the same rule twice: +//! +//! * it never re-derives a render. `own_bridge::render_finding` and +//! `own_bridge::build_sarif` are byte-pinned by the BR-V9 family and are the +//! only renderers called here; +//! * it never re-derives a verdict. `own_bridge::check_facts` is the analysis, +//! and this module only decides *which* of its findings are shown, where they +//! go, and what the run exits with. +//! +//! What it *does* own is the display policy, which is CLI logic rather than +//! bridge logic and lives here for that reason. It is a pure function of the +//! findings and the three options ([`display`]), so the traps below are unit +//! tests rather than process invocations. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use own_bridge::{build_sarif, check_facts, render_finding, Finding}; +use own_ir::{OwnIr, OwnIrError, OwnIrErrorKind}; + +use crate::{pyrepr::py_repr, sarif, text, Outcome}; + +/// The reference's `_FORMATS`, in the order `', '.join(sorted(_FORMATS))` +/// produces — `json` included, because the value gate that rejects an unknown +/// `--format` is the GLOBAL one, and it does not know the command yet. +const FORMATS: [&str; 5] = ["github", "human", "json", "msbuild", "sarif"]; +const SEVERITIES: [&str; 2] = ["error", "warning"]; +const VERBOSITIES: [&str; 3] = ["normal", "quiet", "verbose"]; + +/// The parsed `ownir` invocation. Exactly one positional; both `--flag value` +/// and `--flag=value`; no `--` separator and no short flags (C-3). +struct Parsed { + path: String, + format: String, + severity: String, + verbosity: String, +} + +const fn usage_error(message: String) -> Outcome { + Outcome::new(String::new(), message, 2) +} + +/// The reference's answer to a positional-count error or an unknown argument: +/// the whole module docstring on STDOUT, exit 2. +/// +/// The trailing newline is `print`'s, not the docstring's — `__doc__` ends in +/// exactly one `\n` and `print(__doc__)` puts two on the wire. The constant +/// stays the docstring verbatim and the newline is added here, so the thing +/// named `OWNLANG_DOCSTRING` is the thing it is named after. +fn docstring_usage() -> Outcome { + Outcome::new(format!("{}\n", text::OWNLANG_DOCSTRING), String::new(), 2) +} + +/// The reference's parser, reproduced including the part that surprises: an +/// unknown flag is **not** an error to it. `--bogus` matches no flag, so it +/// falls through to `positional.append(a)` — which means `--bogus ` is +/// two positionals (the docstring) while `--bogus` alone is one, and takes the +/// ordinary path where opening a file called `--bogus` fails. Both halves are +/// frozen, as separate cases. +fn parse(args: &[String]) -> Result { + let mut format = "human".to_owned(); + let mut severity = "error".to_owned(); + let mut verbosity = "normal".to_owned(); + let mut positional: Vec<&str> = Vec::new(); + + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + let mut matched = false; + for (flag, slot) in [ + ("--format", &mut format), + ("--severity", &mut severity), + ("--verbosity", &mut verbosity), + ] { + if arg == flag { + let Some(value) = iter.next() else { + return Err(usage_error(format!("{flag} requires a value\n"))); + }; + slot.clone_from(value); + matched = true; + break; + } + if let Some(value) = arg.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) { + value.clone_into(slot); + matched = true; + break; + } + } + if !matched { + positional.push(arg); + } + } + + // Exactly one positional; zero or extra is a usage error, and the answer is + // the whole module docstring on STDOUT (C-1: the contract as measured, and + // flagged `oracle: "python-docstring"` in the manifest so the owner can + // declare that class a defect knowing exactly what was frozen). + let [path] = positional[..] else { + return Err(docstring_usage()); + }; + + // Order matters: --format is validated before --severity before + // --verbosity, so `--format x --severity y` answers about the format. + for (flag, value, allowed) in [ + ("--format", &format, FORMATS.as_slice()), + ("--severity", &severity, SEVERITIES.as_slice()), + ("--verbosity", &verbosity, VERBOSITIES.as_slice()), + ] { + if !allowed.contains(&value.as_str()) { + return Err(usage_error(format!( + "unknown {flag} {} (choose: {})\n", + py_repr(value), + allowed.join(", ") + ))); + } + } + // `json` passes the global gate above and is refused HERE, by the ownir + // branch, with its own wording — which names the four surfaces in its own + // order rather than the sorted one. + if format == "json" { + return Err(usage_error( + "ownir --format must be one of github/human/msbuild/sarif (got 'json')\n".to_owned(), + )); + } + Ok(Parsed { + path: path.to_owned(), + format, + severity, + verbosity, + }) +} + +/// One `own-cli ownir ...` invocation, from argv to streams and an exit code. +pub(crate) fn run(args: &[String]) -> Outcome { + // C-1's one declared defect: the reference reads `--help` as a file name + // and dies with "cannot read". That is NOT ported — the shell convention + // extends to the subcommand instead. Answered before the parser sees it, + // because to the parser it is just a positional. + if args.iter().any(|a| a == "--help" || a == "-h") { + return Outcome::new(text::OWNIR_USAGE.to_owned(), String::new(), 0); + } + let parsed = match parse(args) { + Ok(parsed) => parsed, + Err(outcome) => return outcome, + }; + // The forced-panic / forced-death controls, when the off-by-default + // `fault-injection` feature is on. Placed after parsing so the control + // exercises the same path a real panic would take. + crate::faults::maybe_inject(); + check(&parsed) +} + +/// The refusal shape both doors share: `{path}: error: {message}`, exit 2, with +/// the path exactly **as given in argv** — never resolved, never canonicalized. +fn refusal(path: &str, message: &str) -> Outcome { + Outcome::new(String::new(), format!("{path}: error: {message}\n"), 2) +} + +/// The internal prefix `own-ir` puts on every `Json`-kind rejection. It is an +/// invariant of that crate, and this module treats it as one: see +/// [`strict_door_refusal`]. +const RUST_JSON_PREFIX: &str = "not valid JSON: "; + +/// The CLI-owned half of a JSON-syntax refusal, byte-exact with the reference. +/// +/// The reference bakes the path into the message inside `load()` and prints it +/// again in `cmd_ownir`, so the line carries the path **twice**: +/// `{path}: error: {path} is not valid JSON: `. `own-ir` cannot write +/// that half — `from_json(&str)` has no path — so the CLI supplies it. +fn json_wrapper(path: &str, detail: &str) -> String { + format!("{path}: error: {path} is not valid JSON: {detail}\n") +} + +/// A strict-door rejection, rendered. +/// +/// # CLI-B1 — `JSON_PARSER_DETAIL`, and its guard +/// +/// One boundary is declared here and it is typed, not a licence for +/// strict-door wording to differ: +/// +/// ```text +/// CLI-B1 JSON_PARSER_DETAIL (applies iff OwnIrErrorKind == Json) +/// pinned: exit 2 · stderr · kind == Json · the FULL CLI-owned wrapper, +/// byte-exact: "{path}: error: {path} is not valid JSON: " +/// declared: only the bytes AFTER that prefix — the parser library's own +/// text (CPython "Expecting value: line 1 column 1 (char 0)" +/// vs serde_json "EOF while parsing a value at line 1 column 0") +/// ``` +/// +/// Every other rejection family — Version, Shape, Vocabulary, Identity, +/// Location — is byte-exact and never reaches the relaxed matcher. #261 ruling +/// 2a made Version byte-exact rather than declaring it (see +/// `own_ir::pyrepr`); this boundary covers the parser detail alone. +/// +/// **The guard has a lock in it.** A `Json` rejection whose message does not +/// start with `own-ir`'s own [`RUST_JSON_PREFIX`] is a broken internal +/// invariant of the Rust implementation, not a JSON rejection to pass through. +/// Letting the adapter swallow its own structural drift as "the tail" is the +/// failure mode a guard on `kind` alone would hide, so that case takes the +/// **internal-error path — rc 70, one actionable diagnostic — and CLI-B1 does +/// NOT apply**. Never rc 2, and never the whole message as the detail. +fn strict_door_refusal(path: &str, refused: &OwnIrError) -> Outcome { + if refused.kind != OwnIrErrorKind::Json { + return refusal(path, &refused.message); + } + match refused.message.strip_prefix(RUST_JSON_PREFIX) { + Some(detail) => Outcome::new(String::new(), json_wrapper(path, detail), 2), + None => Outcome::internal_error(&format!( + "the strict door returned a Json rejection whose message does not begin \ + with {RUST_JSON_PREFIX:?}: {:?}", + refused.message + )), + } +} + +fn check(parsed: &Parsed) -> Outcome { + let path = parsed.path.as_str(); + // Read ONCE, as bytes, and decode once: `OwnIr::from_json` takes `&str` and + // the reference opens the file with `encoding="utf-8"`. + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + // The reference's OSError branch. Its tail is platform-native, which is + // why the fixture matches it through the one `` placeholder. + Err(err) => return refusal(path, &format!("cannot read {path}: {err}")), + }; + let text = match std::str::from_utf8(&bytes) { + Ok(text) => text, + // A DECLARED REFERENCE DEFECT (#261 ruling 1; note §5.1): the + // reference's `load()` converts OSError and JSONDecodeError and nothing + // else, so a UnicodeDecodeError escapes to its exit-70 catch-all — a + // quirk of the reference rather than a designed refusal. rc 70 with the + // internal-error shape is sufficient for #261 and no fixture case + // freezes the bytes: an oracle exists (Python printed one), and we + // DECLINE to make a CPython exception's wording a cross-language + // contract. The Python-first repair — UnicodeDecodeError -> OwnIRError + // -> rc 2 — is a hygiene tail under #250/#262, to close before public + // cutover. + Err(err) => return Outcome::internal_error(&format!("{path}: {err}")), + }; + let facts = match OwnIr::from_json(text) { + Ok(facts) => facts, + Err(refused) => return strict_door_refusal(path, &refused), + }; + match check_facts(&facts) { + Err(refused) => refusal(path, &refused.to_string()), + Ok(findings) => display( + path, + &findings, + &parsed.format, + &parsed.severity, + &parsed.verbosity, + ), + } +} + +/// The reference's `cmd_ownir` display policy, as a pure function. +/// +/// The traps it encodes, each one measured on this tree and each one a named +/// fixture control: +/// +/// * the exit code is `1 if leaks else 0` — `--severity` and `--verbosity` +/// never touch it, and the docstring's "non-zero if any error-level +/// diagnostic" is not the contract; +/// * `quiet` shows only leaks and the summary says `(N advisory hidden)`; +/// * the `ok` line fires on `not shown`, so a document whose only findings are +/// suppressed prints `ok` AND a suppressed tally, at exit 0; +/// * the verbose breakdown counts **every** finding, suppressed included; +/// * SARIF carries `shown + suppressed`, and `build_sarif` applies the +/// per-finding severity rule itself; +/// * machine formats send the summary to stderr, `human` to stdout — so a +/// clean `github`/`msbuild` run writes zero bytes to stdout. +fn display( + path: &str, + findings: &[Finding], + format: &str, + severity: &str, + verbosity: &str, +) -> Outcome { + // `Finding` carries no `suppressed` accessor: it is `ignore_reason.is_some()`, + // the twin of the reference's `ignore_reason is not None`. BR-V6 keeps a + // reason-less `[OwnIgnore]` from ever suppressing, and it does so upstream — + // the extractor never emits one — which the empty-reason fixture proves from + // the outside rather than assuming from the type. + let suppressed: Vec<&Finding> = findings + .iter() + .filter(|f| f.ignore_reason.is_some()) + .collect(); + let active: Vec<&Finding> = findings + .iter() + .filter(|f| f.ignore_reason.is_none()) + .collect(); + let leaks: Vec<&Finding> = active.iter().copied().filter(|f| !f.advisory).collect(); + let notes: Vec<&Finding> = active.iter().copied().filter(|f| f.advisory).collect(); + let shown: &[&Finding] = if verbosity == "quiet" { + &leaks + } else { + &active + }; + + let mut payload = String::new(); + if format == "sarif" { + // One document for the whole run. Suppressed findings ride along so a + // SARIF consumer can count them rather than lose them. + let listed: Vec = shown + .iter() + .chain(suppressed.iter()) + .map(|f| (*f).clone()) + .collect(); + match sarif::render(&build_sarif(&listed, severity)) { + Ok(text) => payload.push_str(&text), + Err(err) => return Outcome::internal_error(&format!("SARIF serialization: {err}")), + } + } else { + for finding in shown { + // The weaker of the host's severity and the finding's own level: an + // advisory is always a warning, `--severity warning` downgrades + // everything, and a finding whose source lifetime could not be + // proven shows as a warning even at the default error level. + let level = if finding.advisory + || severity == "warning" + || finding.severity.as_deref() == Some("warning") + { + "warning" + } else { + severity + }; + payload.push_str(&render_finding(finding, format, level)); + payload.push('\n'); + } + } + + let mut summary = String::new(); + if shown.is_empty() { + let _ = writeln!(summary, "{path}: ok \u{2014} no subscription leaks found"); + } + let leak_count = leaks.len(); + let plural = if leak_count == 1 { "" } else { "s" }; + let _ = write!(summary, "\n{leak_count} finding{plural}"); + if !notes.is_empty() { + let hidden = notes.len(); + if verbosity == "quiet" { + let _ = write!(summary, " ({hidden} advisory hidden)"); + } else { + // Name the codes actually present rather than hardcoding OWN050: + // OBL005 and the OWN051/OWN052 interprocedural notes ride the same + // advisory band. + let mut codes: Vec<&str> = notes.iter().map(|f| f.code.as_str()).collect(); + codes.sort_unstable(); + codes.dedup(); + let _ = write!(summary, ", {hidden} advisory ({})", codes.join("/")); + } + } + if !suppressed.is_empty() { + let _ = write!(summary, ", {} suppressed ([OwnIgnore])", suppressed.len()); + } + summary.push_str(".\n"); + if verbosity == "verbose" && !findings.is_empty() { + let mut by_code: BTreeMap<&str, usize> = BTreeMap::new(); + for finding in findings { + let slot = by_code.entry(finding.code.as_str()).or_insert(0_usize); + // `+= 1` is an arithmetic side effect the workspace denies; a count + // that saturates is still a count, and this one cannot reach usize::MAX. + *slot = slot.saturating_add(1); + } + let breakdown: Vec = by_code + .iter() + .map(|(code, count)| format!("{code}={count}")) + .collect(); + let _ = writeln!(summary, " by code: {}", breakdown.join(", ")); + } + + let exit = u8::from(!leaks.is_empty()); + // The stream split: a machine format keeps stdout for the payload a host + // parses and sends the human summary to stderr; `human` puts both on stdout. + if matches!(format, "github" | "msbuild" | "sarif") { + Outcome::new(payload, summary, exit) + } else { + let mut both = payload; + both.push_str(&summary); + Outcome::new(both, String::new(), exit) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::indexing_slicing)] +mod tests { + use super::{display, parse, strict_door_refusal, Parsed}; + use own_bridge::Finding; + use own_ir::{OwnIrError, OwnIrErrorKind}; + + fn finding(code: &str, advisory: bool, ignore: Option<&str>) -> Finding { + Finding { + file: "Vm.cs".to_owned(), + line: 1, + column: None, + code: code.to_owned(), + component: "Vm".to_owned(), + event: "E".to_owned(), + handler: "On".to_owned(), + message: "m".to_owned(), + kind: "subscription token".to_owned(), + advisory, + severity: None, + related: Vec::new(), + flow: Vec::new(), + ignore_reason: ignore.map(str::to_owned), + } + } + + fn parsed(format: &str, severity: &str, verbosity: &str) -> Parsed { + Parsed { + path: "f.json".to_owned(), + format: format.to_owned(), + severity: severity.to_owned(), + verbosity: verbosity.to_owned(), + } + } + + /// `return 1 if leaks else 0`. Neither option moves it — the campaign's + /// `--severity warning flips the exit` mutant dies here. + #[test] + fn exit_is_independent_of_severity_and_verbosity() { + let leaky = [finding("OWN001", false, None)]; + for severity in ["error", "warning"] { + for verbosity in ["quiet", "normal", "verbose"] { + let p = parsed("human", severity, verbosity); + let out = display("f.json", &leaky, &p.format, &p.severity, &p.verbosity); + assert_eq!(out.exit, 1, "{severity}/{verbosity}"); + } + } + } + + /// An advisory note is not a verdict: it prints, and the run stays clean. + #[test] + fn an_advisory_alone_never_fails_the_run() { + let notes = [finding("OWN050", true, None)]; + let out = display("f.json", ¬es, "human", "error", "normal"); + assert_eq!(out.exit, 0); + assert!(out.stdout.contains("1 advisory (OWN050)"), "{}", out.stdout); + } + + /// A suppression is counted, never silent, and never a verdict. + #[test] + fn a_suppression_is_counted_but_does_not_fail_the_run() { + let only = [finding("OWN001", false, Some("owned by the host"))]; + let out = display("f.json", &only, "human", "error", "normal"); + assert_eq!(out.exit, 0); + assert!(out + .stdout + .contains("ok \u{2014} no subscription leaks found")); + assert!(out + .stdout + .contains("0 findings, 1 suppressed ([OwnIgnore]).")); + } + + /// The `ok` line and the suppressed tally in ONE run — trap 4. + #[test] + fn quiet_hides_the_advisory_and_leaves_the_exit_alone() { + let mixed = [ + finding("OWN001", false, None), + finding("OWN050", true, None), + ]; + let normal = display("f.json", &mixed, "human", "error", "normal"); + let quiet = display("f.json", &mixed, "human", "error", "quiet"); + assert_eq!(normal.exit, quiet.exit); + assert!(normal.stdout.contains(", 1 advisory (OWN050).")); + assert!(quiet.stdout.contains(" (1 advisory hidden).")); + assert!(!quiet.stdout.contains("OWN050]"), "{}", quiet.stdout); + } + + /// The breakdown iterates `findings`, not `shown`. + #[test] + fn the_verbose_breakdown_counts_suppressed_findings_too() { + let docs = [ + finding("OWN001", false, None), + finding("OWN001", false, Some("r")), + finding("OWN050", true, None), + ]; + let out = display("f.json", &docs, "human", "error", "verbose"); + assert!( + out.stdout.contains(" by code: OWN001=2, OWN050=1\n"), + "{}", + out.stdout + ); + } + + /// A clean machine run writes ZERO bytes to stdout; `human` writes there. + #[test] + fn the_stream_split_is_per_format() { + let clean: [Finding; 0] = []; + for format in ["github", "msbuild"] { + let out = display("f.json", &clean, format, "error", "normal"); + assert_eq!( + out.stdout, "", + "{format} must not write to stdout when clean" + ); + assert!(out + .stderr + .contains("ok \u{2014} no subscription leaks found")); + } + let human = display("f.json", &clean, "human", "error", "normal"); + assert_eq!(human.stderr, ""); + assert_eq!( + human.stdout, + "f.json: ok \u{2014} no subscription leaks found\n\n0 findings.\n" + ); + } + + /// `{'s' if n != 1 else ''}` — plural at zero, singular only at one. + #[test] + fn the_summary_pluralizes_on_anything_but_one() { + let clean: [Finding; 0] = []; + assert!(display("f.json", &clean, "human", "error", "normal") + .stdout + .contains("\n0 findings.")); + let one = [finding("OWN001", false, None)]; + assert!(display("f.json", &one, "human", "error", "normal") + .stdout + .contains("\n1 finding.")); + let two = [ + finding("OWN001", false, None), + finding("OWN002", false, None), + ]; + assert!(display("f.json", &two, "human", "error", "normal") + .stdout + .contains("\n2 findings.")); + } + + /// SARIF carries `shown + suppressed`: `quiet` drops the advisory from the + /// list but keeps the suppression. + #[test] + fn sarif_carries_shown_plus_suppressed() { + let docs = [ + finding("OWN001", false, None), + finding("OWN050", true, None), + finding("OWN002", false, Some("r")), + ]; + let normal = display("f.json", &docs, "sarif", "error", "normal"); + let quiet = display("f.json", &docs, "sarif", "error", "quiet"); + assert_eq!(normal.stdout.matches("\"ruleId\"").count(), 3); + assert_eq!(quiet.stdout.matches("\"ruleId\"").count(), 2); + assert!( + quiet.stdout.contains("OWN002"), + "the suppression must ride along" + ); + } + + /// Both spellings, the last-one-wins rule, and the absence of a separator. + #[test] + fn the_parser_accepts_both_spellings_and_lets_the_last_flag_win() { + let args: Vec = ["f.json", "--format=human", "--format", "sarif"] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + let parsed = parse(&args).ok().expect("a valid invocation"); + assert_eq!(parsed.format, "sarif"); + assert_eq!(parsed.path, "f.json"); + } + + /// `--` is an ordinary positional, so `-- f.json` is TWO of them. + #[test] + fn there_is_no_double_dash_separator() { + let args: Vec = ["--", "f.json"].iter().map(|s| (*s).to_owned()).collect(); + let outcome = parse(&args) + .err() + .expect("two positionals is a usage error"); + assert_eq!(outcome.exit, 2); + assert_eq!( + outcome.stdout, + format!("{}\n", crate::text::OWNLANG_DOCSTRING), + "the docstring plus print's own newline" + ); + assert_eq!(outcome.stderr, ""); + } + + /// A missing value is a stderr line, not the docstring. + #[test] + fn a_missing_flag_value_is_its_own_message() { + for flag in ["--format", "--severity", "--verbosity"] { + let args: Vec = ["f.json", flag].iter().map(|s| (*s).to_owned()).collect(); + let outcome = parse(&args).err().expect("a missing value is an error"); + assert_eq!(outcome.exit, 2); + assert_eq!(outcome.stderr, format!("{flag} requires a value\n")); + assert_eq!(outcome.stdout, ""); + } + } + + /// CLI-B1's lock. A `Json` rejection whose message has lost `own-ir`'s own + /// `not valid JSON: ` prefix is a broken internal invariant of the Rust + /// implementation, not a JSON rejection to pass through — so it takes the + /// internal-error path, rc 70, and CLI-B1 does NOT apply. A guard on the + /// kind that then let the adapter eat its own structural drift as "the + /// declared tail" would be a door built with the lock left out. + #[test] + fn a_json_rejection_that_lost_its_prefix_is_an_internal_error_not_rc2() { + let drifted = OwnIrError { + kind: OwnIrErrorKind::Json, + message: "something else entirely".to_owned(), + }; + let out = strict_door_refusal("f.json", &drifted); + assert_eq!( + out.exit, 70, + "never rc 2, and never the whole message as the tail" + ); + assert_eq!(out.stdout, ""); + assert!( + out.stderr.starts_with("own-cli: internal error: "), + "{}", + out.stderr + ); + assert!( + !out.stderr.contains("is not valid JSON"), + "the wrapper must not be emitted for a message that failed the invariant" + ); + + // The well-formed case still takes the ordinary CLI-B1 path. + let ok = OwnIrError { + kind: OwnIrErrorKind::Json, + message: "not valid JSON: EOF while parsing a value at line 1 column 0".to_owned(), + }; + let out = strict_door_refusal("f.json", &ok); + assert_eq!(out.exit, 2); + assert_eq!( + out.stderr, + "f.json: error: f.json is not valid JSON: EOF while parsing a value at line 1 \ + column 0\n" + ); + } + + /// Every non-Json family goes through the plain, byte-exact refusal — the + /// boundary is typed and cannot reach them. + #[test] + fn a_non_json_rejection_never_takes_the_boundary_path() { + for kind in [ + OwnIrErrorKind::Version, + OwnIrErrorKind::Shape, + OwnIrErrorKind::Vocabulary, + ] { + let refused = OwnIrError { + kind, + message: "OwnIR 'ownir_version' must be an integer, got None".to_owned(), + }; + let out = strict_door_refusal("f.json", &refused); + assert_eq!(out.exit, 2, "{kind:?}"); + assert_eq!( + out.stderr, "f.json: error: OwnIR 'ownir_version' must be an integer, got None\n", + "{kind:?}: the path appears ONCE and the message is verbatim" + ); + } + } + + /// The global gate still lists `json`; the ownir branch then refuses it. + #[test] + fn json_passes_the_global_gate_and_is_refused_by_the_ownir_branch() { + let bad: Vec = ["f.json", "--format", "x"] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + assert_eq!( + parse(&bad).err().expect("invalid").stderr, + "unknown --format 'x' (choose: github, human, json, msbuild, sarif)\n" + ); + let json: Vec = ["f.json", "--format", "json"] + .iter() + .map(|s| (*s).to_owned()) + .collect(); + assert_eq!( + parse(&json) + .err() + .expect("json is not an ownir surface") + .stderr, + "ownir --format must be one of github/human/msbuild/sarif (got 'json')\n" + ); + } +} diff --git a/rust/crates/own-cli/src/pyrepr.rs b/rust/crates/own-cli/src/pyrepr.rs new file mode 100644 index 00000000..d0e29ba7 --- /dev/null +++ b/rust/crates/own-cli/src/pyrepr.rs @@ -0,0 +1,125 @@ +//! CPython `repr()` emulation for strings — the usage-message parity tool. +//! +//! The reference interpolates `repr()` of a rejected flag value: +//! +//! ```text +//! unknown --format {fmt!r} (choose: github, human, json, msbuild, sarif) +//! ``` +//! +//! so a byte-identical usage error needs CPython's quoting rules, not Rust's +//! `{:?}` (which would render `it's` as `"it's"` where CPython also picks the +//! double quote, but renders `a\u{1}b` as `"a\u{1}b"` where CPython writes +//! `'a\x01b'`, and escapes printable non-ASCII where CPython does not). +//! +//! **Provenance, and why this is a copy.** The identical helper already exists +//! as `own_syntax::pyrepr::py_repr`, where it serves the parser's `ParseError` +//! / `LexError` text. It is `pub(crate)` there, and exporting it would need an +//! `own-cli -> own-syntax` edge that #261's architecture section does not admit +//! (`own-cli` depends on `own-ir` and `own-bridge`, and nothing else unless the +//! compiler proves otherwise). A forbidden edge is a worse trade than a +//! forty-line pure function carried twice, so the function is carried twice and +//! the duplication is recorded as a tail: a shared `own-pyparity` leaf is the +//! eventual home, and #345 — which adds the commands whose errors interpolate +//! far more `repr()`s — is where it starts to pay. +//! +//! `is_printable` mirrors CPython's `str.isprintable()`: false exactly for the +//! general categories Cc, Cf, Cs, Co, Cn, Zl, Zp and Zs-other-than-space. + +use std::fmt::Write as _; + +use unicode_properties::{GeneralCategory, UnicodeGeneralCategory}; + +/// CPython `str.isprintable()` for one char. (Cs is unreachable — a Rust +/// `char` is never a surrogate — but harmless to name.) +fn is_printable(c: char) -> bool { + if c == ' ' { + return true; + } + !matches!( + c.general_category(), + GeneralCategory::Control // Cc + | GeneralCategory::Format // Cf + | GeneralCategory::Surrogate // Cs + | GeneralCategory::PrivateUse // Co + | GeneralCategory::Unassigned // Cn + | GeneralCategory::LineSeparator // Zl + | GeneralCategory::ParagraphSeparator // Zp + | GeneralCategory::SpaceSeparator // Zs (space itself handled above) + ) +} + +/// `repr(s)` as CPython writes it: single quotes by default, double quotes +/// when the string contains a `'` and no `"`; the backslash, the chosen quote, +/// `\n`, `\r` and `\t` escaped; every other non-printable character as +/// `\xNN` / `\uNNNN` / `\UNNNNNNNN` by code-point width; printable characters, +/// non-ASCII included, left alone. +pub(crate) fn py_repr(s: &str) -> String { + let has_single = s.contains('\''); + let has_double = s.contains('"'); + let quote = if has_single && !has_double { '"' } else { '\'' }; + let mut out = String::with_capacity(s.len().saturating_add(2)); + out.push(quote); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c == quote => { + out.push('\\'); + out.push(c); + } + c if !is_printable(c) => { + // CPython picks the escape by code-point width. `write!` into a + // String cannot fail; the Ok(()) is discarded rather than + // unwrapped (the workspace denies `unwrap_used`). + let cp = c as u32; + if cp < 0x100 { + let _ = write!(out, "\\x{cp:02x}"); + } else if cp < 0x1_0000 { + let _ = write!(out, "\\u{cp:04x}"); + } else { + let _ = write!(out, "\\U{cp:08x}"); + } + } + c => out.push(c), + } + } + out.push(quote); + out +} + +#[cfg(test)] +mod tests { + use super::py_repr; + + /// The values a `--format`/`--severity`/`--verbosity` error can actually + /// carry, and the quoting rules behind them. The first three are frozen by + /// fixture cases (`usage-format-invalid`, `usage-format-empty-value`, + /// `usage-format-value-is-a-flag`); the rest keep the helper honest for + /// the values a user could still type. + #[test] + fn matches_cpython_quoting() { + assert_eq!(py_repr("x"), "'x'"); + assert_eq!(py_repr(""), "''"); + assert_eq!(py_repr("--severity"), "'--severity'"); + assert_eq!(py_repr("it's"), "\"it's\""); + assert_eq!(py_repr("say \"hi\""), "'say \"hi\"'"); + assert_eq!(py_repr("both ' and \""), "'both \\' and \"'"); + assert_eq!(py_repr("a\nb\tc\\d"), "'a\\nb\\tc\\\\d'"); + assert_eq!(py_repr("\u{1}"), "'\\x01'"); + } + + /// Produced by running CPython `repr()` on each character. + #[test] + fn matches_cpython_nonprintable_escapes() { + assert_eq!(py_repr("\u{200b}"), r"'\u200b'"); // Cf zero-width space + assert_eq!(py_repr("\u{feff}"), r"'\ufeff'"); // Cf BOM + assert_eq!(py_repr("\u{a0}"), r"'\xa0'"); // Zs NBSP, below U+0100 + assert_eq!(py_repr("\u{85}"), r"'\x85'"); // Cc C1 control + assert_eq!(py_repr("\u{2028}"), r"'\u2028'"); // Zl line separator + assert_eq!(py_repr("\u{10ffff}"), r"'\U0010ffff'"); // Cn astral + assert_eq!(py_repr("\u{1f600}"), "'\u{1f600}'"); // So printable emoji + assert_eq!(py_repr("\u{e9} \u{436}"), "'\u{e9} \u{436}'"); // printable non-ASCII + } +} diff --git a/rust/crates/own-cli/src/sarif.rs b/rust/crates/own-cli/src/sarif.rs new file mode 100644 index 00000000..48008f26 --- /dev/null +++ b/rust/crates/own-cli/src/sarif.rs @@ -0,0 +1,109 @@ +//! The CLI's SARIF serialization — and why it is not the fixture emitter's. +//! +//! `own_bridge::build_sarif` is the single source of the log's *shape*, and it +//! is byte-pinned by the BR-V9 family +//! (`rust/crates/own-bridge/tests/renders.rs` against +//! `tests/fixtures/verdict_renders/`). This module does not re-derive one byte +//! of it. What it owns is the **serialization**, because the reference writes +//! the same document twice, differently: +//! +//! | surface | emitter | non-ASCII | +//! |---|---|---| +//! | `cmd_ownir`'s `--format sarif` stdout | `json.dumps(..., indent=2)` — `ensure_ascii` left at its **default, True** | escaped `\uXXXX` | +//! | `tests/fixtures/verdict_renders/*.renders.json` | the fixture writer, `ensure_ascii=False` | literal UTF-8 | +//! +//! Measured on `render_tiers_and_levels`: the CLI's stdout is pure ASCII and +//! carries the six ASCII characters `\u2014` where the message has an em +//! dash; the golden for the same document carries the `e2 80 94` bytes. So the goldens' emitter is **not** +//! wrong and is not to be "fixed" — the two byte shapes are both correct, for +//! different consumers, and this is the CLI's. +//! +//! The escaping is applied to the finished pretty-printed text rather than +//! through a custom `serde_json::ser::Formatter`, and that is a correctness +//! argument rather than a convenience: every character of JSON *syntax* is +//! ASCII, so any non-ASCII scalar in the serialized document is necessarily +//! inside a string literal, where `\uXXXX` is exactly the right escape. A +//! hand-rolled formatter would have to re-implement every pretty-printing hook +//! correctly to earn the same guarantee. + +use std::fmt::Write as _; + +use own_bridge::SarifLog; + +/// Escape every non-ASCII scalar as CPython's `json.dumps` does: lowercase +/// `\uXXXX`, and a **surrogate pair** for anything above the BMP (measured: +/// `U+1F600` in a `file` field leaves as `\ud83d\ude00`). `char::encode_utf16` +/// produces exactly that pair, so the rule is one branch rather than a manual +/// surrogate computation the denied `arithmetic_side_effects` lint would +/// rightly object to. +fn escape_non_ascii(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut units = [0_u16; 2]; + for ch in text.chars() { + if ch.is_ascii() { + out.push(ch); + continue; + } + for unit in ch.encode_utf16(&mut units) { + // `write!` into a String is infallible; the Result is discarded + // rather than unwrapped (the workspace denies `unwrap_used`). + let _ = write!(out, "\\u{unit:04x}"); + } + } + out +} + +/// The document as `cmd_ownir` writes it to stdout: two-space indent, the +/// `": "` / `", "` separators `json.dumps` uses when `indent` is given, ASCII +/// escaping, and exactly one trailing newline (the reference's `print`). +/// +/// # Errors +/// +/// Returns the `serde_json` error if the typed log fails to serialize. That +/// cannot happen for `SarifLog` — every field is a plain owned scalar or a +/// `Vec` of them — but it is reported rather than unwrapped: the workspace +/// denies `unwrap_used`, and a CLI that panicked on its own output would be +/// answering an internal-error question nobody asked. +pub(crate) fn render(log: &SarifLog) -> Result { + let pretty = serde_json::to_string_pretty(log)?; + let mut out = escape_non_ascii(&pretty); + out.push('\n'); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::escape_non_ascii; + + /// The three characters that matter, against CPython's `json.dumps` + /// output as measured on this tree (H.0): ASCII untouched, a BMP scalar as + /// one lowercase `\uXXXX`, an astral scalar as a surrogate PAIR. + #[test] + fn escapes_exactly_as_json_dumps_does() { + assert_eq!(escape_non_ascii("plain ASCII"), "plain ASCII"); + assert_eq!(escape_non_ascii("a \u{2014} b"), r"a \u2014 b"); + assert_eq!(escape_non_ascii("\u{1f600}"), r"\ud83d\ude00"); + assert_eq!( + escape_non_ascii("\u{dc}n\u{ef}c\u{f8}d\u{e9}"), + r"\u00dcn\u00efc\u00f8d\u00e9" + ); + } + + /// Lowercase hex, and zero-padded to four digits — `json.dumps` writes + /// `\u00dc`, never `\u00DC` and never `\uDC`. + #[test] + fn hex_is_lowercase_and_padded() { + assert_eq!(escape_non_ascii("\u{e9}"), r"\u00e9"); + assert_eq!(escape_non_ascii("\u{412}"), r"\u0412"); + } + + /// A JSON structural character is always ASCII, so escaping the finished + /// text can only ever touch the inside of a string literal. This is the + /// module's correctness argument, asserted rather than asserted-in-prose. + #[test] + fn json_syntax_is_entirely_ascii() { + for ch in ['{', '}', '[', ']', ':', ',', '"', '\\', '\n', ' '] { + assert!(ch.is_ascii(), "{ch:?} is JSON syntax and must be ASCII"); + } + } +} diff --git a/rust/crates/own-cli/src/text.rs b/rust/crates/own-cli/src/text.rs new file mode 100644 index 00000000..1cc3433e --- /dev/null +++ b/rust/crates/own-cli/src/text.rs @@ -0,0 +1,118 @@ +//! The three frozen text surfaces the binary prints, and the one rule about +//! how they are written down. +//! +//! Each is a `concat!` of one string literal PER SOURCE LINE, every literal +//! ending in `\n`. That is not a style choice: a checkout with +//! `core.autocrlf` on rewrites line *endings*, and no literal here contains +//! one, so the bytes this binary prints cannot depend on how the tree was +//! checked out. A single multi-line raw string would carry `\r\n` on such a +//! checkout and silently fail the byte contract on Windows only. (#343.) +//! +//! * [`OWNLANG_DOCSTRING`] is the REFERENCE's module docstring, reproduced +//! byte for byte. `python -m ownlang ownir` prints it to stdout with exit 2 +//! for a positional-count error or an unknown argument, and C-1 draws the +//! oracle boundary at the surface rather than at that internal print branch +//! — so these bytes are the contract as measured. The fixture marks that +//! class `oracle: "python-docstring"` so the owner can see what was frozen. +//! * [`SHELL_USAGE`] and [`OWNIR_USAGE`] have NO Python oracle. They are the +//! `owen`-convention parity surface C-1 creates, authored once and shared +//! with `tests/fixtures/cli_ownir/manifest.json`; `tests/replay.rs` asserts +//! the two copies are equal, so they cannot drift apart. + +/// The reference's module docstring, byte for byte (`ownlang/__main__.py`). +pub(crate) const OWNLANG_DOCSTRING: &str = concat!( + "\n", + "Command-line driver for the OwnLang PoC.\n", + "\n", + " python -m ownlang check file.own # report ownership diagnostics\n", + " python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n", + " python -m ownlang emit file.own # check, then print generated C#\n", + " python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n", + " python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n", + " python -m ownlang report file.own # buffer storage report + .ownreport.json\n", + " python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n", + " python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n", + " python -m ownlang summaries facts.json # dump solved method-ownership summaries\n", + " # (MOS) + extern log — deterministic JSON\n", + " python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n", + " python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n", + "\n", + "`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\n", + "roslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\n", + "and how to fix it. It lives in the core, next to the catalogue, because there is one\n", + "checker — the C# extractor emits facts, it does not own the diagnostics.\n", + "\n", + "`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n", + "`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n", + "(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\n", + "On `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\n", + "SARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n", + "`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n", + "`--severity` (ownir only) picks how the host shows a finding — `error` (default,\n", + "fails a build / red check) or `warning` (advisory). It is a presentation choice;\n", + "the finding is still the core's verdict.\n", + "`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\n", + "OWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\n", + "OWN052 \"summaries skipped\"), `normal` (default), or `verbose` (also print a\n", + "per-code breakdown).\n", + "\n", + "Exit code is non-zero if any error-level diagnostic was produced.\n", +); + +/// The top-level shell's help. Shared with the fixture manifest. +pub(crate) const SHELL_USAGE: &str = concat!( + "own-cli — the Own.NET core as a native executable.\n", + "\n", + "Usage:\n", + " own-cli ownir [options] check OwnIR facts extracted from C#\n", + "\n", + "Options (ownir):\n", + " --format {human|github|msbuild|sarif} finding surface (default: human)\n", + " --severity {error|warning} how a finding is shown (default: error)\n", + " --verbosity {quiet|normal|verbose} quiet hides the advisory notes;\n", + " verbose adds a per-code breakdown\n", + " (default: normal)\n", + " --help, -h print this help\n", + " --version print the version\n", + "\n", + "Both `--flag value` and `--flag=value` are accepted. `ownir` takes exactly one\n", + "positional argument, the facts file; there is no `--` separator and there are\n", + "no short flags.\n", + "\n", + "Exit codes:\n", + " 0 clean\n", + " 1 findings — any non-advisory, unsuppressed finding, independent of\n", + " --severity\n", + " 2 usage error, or a facts document the strict door refuses\n", + " 70 internal error — a bug in the analyzer, never silence\n", +); + +/// The `ownir` subcommand's usage. C-1 declares the reference's reaction to +/// `--help` a defect and does not port it: this is printed to stdout with exit +/// 0 instead, the shell convention extended to the subcommand. +pub(crate) const OWNIR_USAGE: &str = concat!( + "own-cli ownir — check OwnIR facts extracted from C# by the Roslyn frontend.\n", + "\n", + "Usage:\n", + " own-cli ownir [--format F] [--severity S] [--verbosity V]\n", + "\n", + "Options:\n", + " --format {human|github|msbuild|sarif} human is the default CLI line, github\n", + " a CI annotation, msbuild the VS Error\n", + " List line, sarif a SARIF 2.1.0 log\n", + " --severity {error|warning} how the host shows a finding; it never\n", + " changes the exit code\n", + " --verbosity {quiet|normal|verbose} quiet hides the advisory notes\n", + " (OWN050/051/052, OBL005); verbose adds\n", + " a per-code breakdown over every\n", + " finding, suppressed ones included\n", + "\n", + "Both `--flag value` and `--flag=value` are accepted. Exactly one positional\n", + "argument; there is no `--` separator and there are no short flags.\n", + "\n", + "Exit codes:\n", + " 0 no leaks\n", + " 1 at least one non-advisory, unsuppressed finding\n", + " 2 usage error, or a facts document the strict door refuses\n", + " 70 internal error — a bug in the analyzer, never silence\n", +); diff --git a/rust/crates/own-cli/tests/faults.rs b/rust/crates/own-cli/tests/faults.rs new file mode 100644 index 00000000..510d437b --- /dev/null +++ b/rust/crates/own-cli/tests/faults.rs @@ -0,0 +1,171 @@ +//! The two forced-failure controls, MEASURED rather than asserted from the +//! design — P-022 step 7b, #261. +//! +//! The whole file is behind the off-by-default `fault-injection` feature, so a +//! production build compiles none of it and the binary it tests carries no +//! hooks. Run it with: +//! +//! ```text +//! cargo test -p own-cli --features fault-injection --test faults +//! ``` +//! +//! #261's two rulings, and the difference between them: +//! +//! ```text +//! catchable panic -> exactly one actionable stderr diagnostic + exit 70, +//! never 101. Asserted precisely, because the number is +//! the contract: #262's launcher maps 70, and only 70, +//! onto its internal-error path. +//! uncatchable death -> a visible hard failure: non-zero, OUTSIDE +//! {0, 1, 2, 70}, nothing on stdout, never masked. No +//! particular OS exit number is contracted, so none is +//! asserted — a signal on Unix and an abort status on +//! Windows are both correct answers, and the number this +//! run produced is recorded in the note instead. +//! ``` + +#![cfg(feature = "fault-injection")] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::PathBuf; +use std::process::{Command, Output}; + +const FIXTURE_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/cli_ownir" +); + +/// A perfectly ordinary invocation — one that exits 0 without the hooks — so +/// the only thing the control changes is the fault it injects. +fn run(fault: &str, debug: bool) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_own-cli")); + command + .args(["ownir", "../verdict_renders/render_empty.facts.json"]) + .current_dir(PathBuf::from(FIXTURE_DIR)) + .env_remove("OWNLANG_DEBUG") + .env(fault, "1"); + if debug { + command.env("OWNLANG_DEBUG", "1"); + } + command.output().expect("own-cli runs") +} + +/// A catchable panic is exactly one diagnostic and exit 70 — never 101, never +/// a finding, never an `ok` line. +#[test] +fn a_catchable_panic_is_one_diagnostic_and_exit_70() { + let out = run("OWN_CLI_FAULT_PANIC", false); + assert_eq!( + out.status.code(), + Some(70), + "a panic must exit 70 (EX_SOFTWARE), never 101 — #262's launcher maps \ + only 70 onto its internal-error path" + ); + assert!( + out.stdout.is_empty(), + "a panic must produce no findings and no `ok` line, got {:?}", + String::from_utf8_lossy(&out.stdout) + ); + let stderr = String::from_utf8_lossy(&out.stderr); + let lines: Vec<&str> = stderr.lines().collect(); + assert_eq!( + lines.len(), + 2, + "EXACTLY one diagnostic (two lines), got: {stderr:?}" + ); + assert!( + lines[0].starts_with("own-cli: internal error: "), + "the first line names the failure: {:?}", + lines[0] + ); + assert!( + lines[0].contains("forced panic (fault-injection)"), + "the payload is carried through, not replaced by a generic word: {:?}", + lines[0] + ); + assert!( + lines[1].contains("This is a bug in the analyzer, not in your code"), + "the second line tells the user it is not theirs: {:?}", + lines[1] + ); + assert!( + !stderr.contains("note: run with `RUST_BACKTRACE"), + "the hook must SUPPRESS the default panic output" + ); + assert!( + !stderr.contains("stack backtrace"), + "no backtrace unless OWNLANG_DEBUG asks for one: {stderr:?}" + ); +} + +/// The debug half of the reference's asymmetry: the technical cause is shown +/// and the exit code is kept. `run()` in `ownlang/__main__.py` does exactly +/// this, and for exactly this reason — a re-raise would exit 1, which a caller +/// reads as findings. +#[test] +fn debug_mode_shows_the_backtrace_and_still_exits_70() { + let out = run("OWN_CLI_FAULT_PANIC", true); + assert_eq!( + out.status.code(), + Some(70), + "OWNLANG_DEBUG shows more; it must not change the exit code" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("forced panic (fault-injection)"), + "the payload is printed: {stderr:?}" + ); + assert!( + stderr.contains("own-cli: panic:"), + "the hook's debug line names the panic and its location: {stderr:?}" + ); + assert!( + out.stdout.is_empty(), + "still no findings and no `ok` line under debug" + ); +} + +/// An uncatchable death is a VISIBLE hard failure. What is asserted is that it +/// cannot be confused with any legal outcome; what is deliberately NOT asserted +/// is the number, because #261 contracts no OS exit code for this case. +#[test] +fn an_uncatchable_death_is_a_visible_hard_failure() { + let out = run("OWN_CLI_FAULT_ABORT", false); + assert!( + !out.status.success(), + "an abort must never look like a clean run" + ); + assert!( + out.stdout.is_empty(), + "an abort must produce no findings and no `ok` line, got {:?}", + String::from_utf8_lossy(&out.stdout) + ); + // `code()` is None when a signal killed the process (Unix); either way the + // outcome must be outside the legal set, so a caller cannot read it as + // clean, findings, a usage error or a handled internal error. + if let Some(code) = out.status.code() { + assert!( + ![0, 1, 2, 70].contains(&code), + "an uncatchable death produced {code}, which is inside the legal \ + set {{0, 1, 2, 70}} — a caller would mistake it for an ordinary \ + outcome" + ); + } +} + +/// The control has to be a control: without the environment variable the same +/// invocation is an ordinary clean run, so the two tests above are measuring +/// the fault rather than a broken binary. +#[test] +fn without_the_environment_variable_the_same_invocation_is_clean() { + let out = Command::new(env!("CARGO_BIN_EXE_own-cli")) + .args(["ownir", "../verdict_renders/render_empty.facts.json"]) + .current_dir(PathBuf::from(FIXTURE_DIR)) + .env_remove("OWNLANG_DEBUG") + .env_remove("OWN_CLI_FAULT_PANIC") + .env_remove("OWN_CLI_FAULT_ABORT") + .output() + .expect("own-cli runs"); + assert_eq!(out.status.code(), Some(0)); + assert!(String::from_utf8_lossy(&out.stdout).contains("0 findings.")); +} diff --git a/rust/crates/own-cli/tests/replay.rs b/rust/crates/own-cli/tests/replay.rs new file mode 100644 index 00000000..0129f1df --- /dev/null +++ b/rust/crates/own-cli/tests/replay.rs @@ -0,0 +1,565 @@ +//! Zero-Python replay of the frozen `own-cli ownir` CLI contract +//! (`tests/fixtures/cli_ownir/`, authoritative via +//! `python tests/test_cli_ownir_fixtures.py --write`) — P-022 step 7b, #261. +//! +//! The Python side of this contract proves the frozen bytes are still what +//! `python -m ownlang ownir` produces. **This side runs no Python at all**: it +//! builds the binary, runs it against the same bytes, and compares. That +//! separation is the whole point of the fixture — a replay that needed the +//! reference would prove the two agree on a machine that has both, which is +//! not the machine the cutover is for. +//! +//! What is compared is exit code, stdout bytes and stderr bytes. There is +//! exactly one placeholder, ``, and one rule for it: it consumes the +//! rest of the line it appears on and what it consumed must be non-empty. The +//! `cannot read : ` prefix in front of it stays byte-exact, so the +//! contract is the CLI's own sentence and only the platform's `strerror` text +//! is left to the platform — the same choice `tests/test_cli_contract.py` +//! already made with its `"cannot read"` substring, written down instead of +//! implied. +//! +//! Three structural checks come before any byte comparison, because a fixture +//! that has quietly stopped covering something passes every byte check it still +//! has: every manifest case must have a file, every file a manifest entry, and +//! the `cli_ownir_version` must agree on both sides. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use own_ir::{OwnIr, OwnIrErrorKind}; +use serde_json::Value; + +const FIXTURE_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/cli_ownir" +); +const CLI_OWNIR_VERSION: i64 = 1; +const OS_ERROR: &str = ""; + +fn fixture_dir() -> PathBuf { + PathBuf::from(FIXTURE_DIR) +} + +fn read_json(path: &Path) -> Value { + let text = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + serde_json::from_str(&text) + .unwrap_or_else(|e| panic!("{} is not valid JSON: {e}", path.display())) +} + +fn manifest() -> Value { + read_json(&fixture_dir().join("manifest.json")) +} + +/// A named field, or a failure that says which one is missing. Used instead of +/// `value["key"]` because the workspace denies `indexing_slicing` — and because +/// "the fixture has no `cases`" is a better message than an index panic. +fn field<'a>(value: &'a Value, key: &str) -> &'a Value { + value + .get(key) + .unwrap_or_else(|| panic!("the fixture is missing the field {key:?}")) +} + +/// Run the built binary for one case. `OWNLANG_DEBUG` is removed from the +/// inherited environment before the case's own `env` is applied, so a +/// developer who exported it does not silently change what the fixture means. +fn run_case(case: &Value) -> Output { + let argv: Vec = field(case, "argv") + .as_array() + .expect("argv is an array") + .iter() + .map(|a| a.as_str().expect("argv entries are strings").to_owned()) + .collect(); + let cwd = fixture_dir().join(field(case, "cwd").as_str().expect("cwd is a string")); + let mut command = Command::new(env!("CARGO_BIN_EXE_own-cli")); + command.args(&argv).current_dir(&cwd); + command.env_remove("OWNLANG_DEBUG"); + if let Some(env) = field(case, "env").as_object() { + for (key, value) in env { + command.env(key, value.as_str().expect("env values are strings")); + } + } + command + .output() + .unwrap_or_else(|e| panic!("cannot run own-cli: {e}")) +} + +/// `expected` matches `actual`, with `` consuming the rest of its +/// line — and only if it consumed something. An empty tail would mean the +/// platform said nothing about a failure, which is not a pass. +fn matches(expected: &str, actual: &str) -> bool { + let Some((head, tail)) = expected.split_once(OS_ERROR) else { + return expected == actual; + }; + assert!( + !tail.contains(OS_ERROR), + "a case may carry at most one placeholder" + ); + let Some(rest) = actual.strip_prefix(head) else { + return false; + }; + // The placeholder stops at the newline and hands the newline itself back, + // so whatever the case expects AFTER that line still has to match. + let (consumed, remainder) = match rest.find('\n') { + Some(at) => (rest.get(..at).unwrap_or(""), rest.get(at..).unwrap_or("")), + None => (rest, ""), + }; + !consumed.is_empty() && remainder == tail +} + +/// The prefix CLI-B1 pins, built from the case's own argv rather than from the +/// expectation it is about to relax — deriving it from the thing under test +/// would make the check circular. +/// +/// A CLI-B1 case is required to be exactly `["ownir", ]`: no flags, one +/// positional. That is not a limitation worth working around, it is what makes +/// the prefix unambiguous, and a case that grows a flag fails here rather than +/// silently relaxing more than it declared. +fn cli_b1_pinned_prefix(case: &Value) -> String { + let argv: Vec<&str> = field(case, "argv") + .as_array() + .expect("argv is an array") + .iter() + .map(|a| a.as_str().expect("argv entries are strings")) + .collect(); + assert_eq!( + argv.len(), + 2, + "a CLI-B1 case must be exactly [ownir, ], got {argv:?}" + ); + assert_eq!(argv.first().copied(), Some("ownir"), "{argv:?}"); + let path = argv.get(1).copied().unwrap_or_default(); + format!("{path}: error: {path} is not valid JSON: ") +} + +/// Where a CLI-B1 case's facts live: its own `cwd` joined with its own single +/// positional. Resolved in one place so the on-disk reading and the negative +/// control cannot drift on to different paths. +fn cli_b1_facts_path(case: &Value) -> Result { + let cwd = fixture_dir().join(field(case, "cwd").as_str().expect("cwd is a string")); + let path = field(case, "argv") + .as_array() + .expect("argv is an array") + .get(1) + .and_then(Value::as_str) + .ok_or_else(|| "a CLI-B1 case needs a facts path as its only positional".to_owned())?; + Ok(cwd.join(path)) +} + +/// Would CLI-B1's relaxed tail apply to **these bytes**? Proven, never assumed. +/// +/// The boundary applies **iff** the strict door rejects with +/// `OwnIrErrorKind::Json`, so the guard establishes exactly that before +/// anything is relaxed: +/// +/// 1. decode the supplied bytes with `str::from_utf8`, no normalization; +/// 2. the decode must SUCCEED — invalid UTF-8 is #261 ruling 1's declared +/// reference defect and must never borrow this boundary; +/// 3. `OwnIr::from_json` on that exact `&str` must REJECT; +/// 4. the rejection's kind must be `Json`. +/// +/// Any of those failing means the bytes are not eligible and the caller fails +/// the case. `Err(reason)` says which step, so a broken control names itself. +/// +/// The bytes are a **parameter** rather than something this function fetches, +/// and that is the whole design of the negative control (#261 R3b): the two +/// runs there share one case, so one argv, one exact path and one decode +/// route, and eligibility can only turn on the bytes. A version of this guard +/// that reached for the file itself would make the control compare two +/// different documents at two different paths and prove nothing about either. +fn cli_b1_eligible_for(facts: &[u8]) -> Result<(), String> { + // Steps 1 and 2. Invalid UTF-8 belongs to ruling 1, never here. + let text = std::str::from_utf8(facts).map_err(|e| { + format!( + "the facts are not valid UTF-8 ({e}) — that is #261 ruling 1's \ + declared reference defect, never CLI-B1" + ) + })?; + // Steps 3 and 4. The typed door is the authority on the kind; nothing here + // reads the message to decide. + match OwnIr::from_json(text) { + Ok(_) => { + Err("the strict door ACCEPTED these facts; CLI-B1 needs a Json rejection".to_owned()) + } + Err(refused) if refused.kind == OwnIrErrorKind::Json => Ok(()), + Err(refused) => Err(format!( + "the strict door rejected with kind {:?}, not Json — CLI-B1 applies \ + iff the kind is Json, and every other family is pinned byte-exact", + refused.kind + )), + } +} + +/// Is this case eligible for CLI-B1, judged on the facts it actually ran with? +fn cli_b1_eligible(case: &Value) -> Result<(), String> { + let path = cli_b1_facts_path(case)?; + let bytes = std::fs::read(&path) + .map_err(|e| format!("cannot read the case's facts bytes {}: {e}", path.display()))?; + cli_b1_eligible_for(&bytes) +} + +fn describe(label: &str, expected: &str, actual: &str) -> String { + format!("\n {label} expected: {expected:?}\n {label} actual : {actual:?}") +} + +/// A case name and every file on disk must be the same set: a manifest entry +/// without a file is a case nobody replays, and a file without an entry is a +/// case nobody lists. +#[test] +fn every_manifest_case_has_a_file_and_every_file_an_entry() { + let manifest = manifest(); + assert_eq!( + field(&manifest, "cli_ownir_version").as_i64(), + Some(CLI_OWNIR_VERSION), + "the fixture and this replay disagree about the format version" + ); + let listed: BTreeSet = field(&manifest, "cases") + .as_array() + .expect("cases is an array") + .iter() + .map(|c| field(c, "name").as_str().expect("a case name").to_owned()) + .collect(); + let mut on_disk = BTreeSet::new(); + for entry in std::fs::read_dir(fixture_dir()).expect("the fixture directory exists") { + let name = entry.expect("a readable entry").file_name(); + let name = name.to_string_lossy(); + if let Some(stem) = name.strip_suffix(".case.json") { + on_disk.insert(stem.to_owned()); + } + } + assert!(!listed.is_empty(), "the manifest lists no cases at all"); + assert_eq!( + listed, on_disk, + "the manifest and the case files have drifted apart — run \ + `python tests/test_cli_ownir_fixtures.py --write`" + ); +} + +/// The one that matters: the built binary against the frozen bytes. +#[test] +fn replays_the_whole_cli_contract_byte_for_byte() { + let manifest = manifest(); + let mut failures: Vec = Vec::new(); + let mut replayed = 0_usize; + + for entry in field(&manifest, "cases") + .as_array() + .expect("cases is an array") + { + let name = field(entry, "name").as_str().expect("a case name"); + let case = read_json(&fixture_dir().join(format!("{name}.case.json"))); + assert_eq!( + field(&case, "cli_ownir_version").as_i64(), + Some(CLI_OWNIR_VERSION), + "{name}: format version drift" + ); + let expected = field(&case, "expected"); + let want_exit = field(expected, "exit").as_i64().expect("an exit code"); + let want_out = field(expected, "stdout").as_str().expect("a stdout string"); + let want_err = field(expected, "stderr").as_str().expect("a stderr string"); + + let output = run_case(&case); + let got_out = String::from_utf8_lossy(&output.stdout); + let got_err = String::from_utf8_lossy(&output.stderr); + let got_exit = output.status.code(); + + // CLI-B1: prove the kind BEFORE relaxing anything, then relax only + // the parser detail after the pinned, byte-exact CLI-owned wrapper. + if let Some(boundary) = case.get("boundary") { + let id = field(boundary, "id").as_str().unwrap_or("?"); + assert_eq!(id, "CLI-B1", "{name}: unknown declared boundary {id:?}"); + assert_eq!( + field(boundary, "expected_kind").as_str(), + Some("json"), + "{name}: CLI-B1 applies iff the kind is Json" + ); + if let Err(reason) = cli_b1_eligible(&case) { + failures.push(format!("{name} [CLI-B1 NOT eligible]: {reason}")); + replayed = replayed.saturating_add(1); + continue; + } + let prefix = cli_b1_pinned_prefix(&case); + let mut why = String::new(); + if got_exit != Some(2) { + why.push_str(&format!("\n exit expected: 2, actual: {got_exit:?}")); + } + if !got_out.is_empty() { + why.push_str(&describe("stdout", "", &got_out)); + } + // The wrapper is pinned byte-exact; only what follows is declared. + if got_err.starts_with(&prefix) { + if got_err.len() <= prefix.len() { + why.push_str("\n the declared parser detail was empty"); + } + } else { + why.push_str(&format!( + "\n the CLI-owned wrapper is pinned and did not match\n \ + expected prefix: {prefix:?}\n actual stderr : {got_err:?}" + )); + } + if !why.is_empty() { + failures.push(format!("{name} [boundary: CLI-B1]{why}")); + } + replayed = replayed.saturating_add(1); + continue; + } + + let mut why = String::new(); + if got_exit != Some(want_exit.try_into().unwrap_or(i32::MAX)) { + why.push_str(&format!( + "\n exit expected: {want_exit}, actual: {got_exit:?}" + )); + } + if !matches(want_out, &got_out) { + why.push_str(&describe("stdout", want_out, &got_out)); + } + if !matches(want_err, &got_err) { + why.push_str(&describe("stderr", want_err, &got_err)); + } + if !why.is_empty() { + let oracle = field(entry, "oracle").as_str().unwrap_or("?"); + failures.push(format!("{name} [oracle: {oracle}]{why}")); + } + replayed = replayed.saturating_add(1); + } + + assert!( + failures.is_empty(), + "{} of {replayed} cases diverged from the frozen contract:\n\n{}", + failures.len(), + failures.join("\n\n") + ); + assert!(replayed > 0, "no cases were replayed"); +} + +/// Byte-identical stdout and stderr on rerun. A contract that only holds the +/// first time is not one, and this is cheap enough to run over every case +/// rather than a chosen few. +#[test] +fn every_case_is_deterministic() { + let manifest = manifest(); + let mut unstable: Vec = Vec::new(); + for entry in field(&manifest, "cases") + .as_array() + .expect("cases is an array") + { + let name = field(entry, "name").as_str().expect("a case name"); + let case = read_json(&fixture_dir().join(format!("{name}.case.json"))); + let first = run_case(&case); + let second = run_case(&case); + if first.status.code() != second.status.code() + || first.stdout != second.stdout + || first.stderr != second.stderr + { + unstable.push(name.to_owned()); + } + } + assert!( + unstable.is_empty(), + "these cases did not reproduce byte-for-byte on a second run: {unstable:?}" + ); +} + +/// The `owen`-convention surface has ONE source of truth. It is authored in +/// `tests/test_cli_ownir_fixtures.py`, carried in the manifest, and held as a +/// constant in the binary; nothing here can see that constant (an integration +/// test cannot link a `[[bin]]` crate), so the check goes through the process — +/// which is the stronger form anyway, because it compares what a user sees. +#[test] +fn the_help_text_the_binary_prints_is_the_one_the_manifest_carries() { + let manifest = manifest(); + let shell = field(&manifest, "shell_usage") + .as_str() + .expect("shell_usage"); + let ownir = field(&manifest, "ownir_usage") + .as_str() + .expect("ownir_usage"); + + for flag in ["--help", "-h"] { + let out = Command::new(env!("CARGO_BIN_EXE_own-cli")) + .arg(flag) + .output() + .expect("own-cli runs"); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + shell, + "`own-cli {flag}` has drifted from the manifest's shell_usage" + ); + } + + let out = Command::new(env!("CARGO_BIN_EXE_own-cli")) + .args(["ownir", "--help"]) + .output() + .expect("own-cli runs"); + assert_eq!( + String::from_utf8_lossy(&out.stdout), + ownir, + "`own-cli ownir --help` has drifted from the manifest's ownir_usage" + ); + + // The version the fixture froze must be this package's, or `--version` + // would be pinned to a number nobody bumps. + assert_eq!( + field(&manifest, "own_cli_version").as_str(), + Some(env!("CARGO_PKG_VERSION")), + "the manifest's own_cli_version and Cargo.toml have drifted" + ); +} + +/// CLI-B1's NEGATIVE CONTROL: the guard is on the rejection KIND and nothing +/// else, so eligibility must flip on the **facts bytes alone**. +/// +/// That is why this control runs **one case** — one argv, one exact path +/// string, one decode route, one fixture — twice, against two byte sequences. +/// Everything a guard could accidentally be keyed on is held literally +/// identical across the two runs, so the only variable left is the content: +/// +/// | run | bytes | strict door | eligible | +/// |---|---|---|---| +/// | positive | the case's own facts, on disk | `Json` | yes | +/// | negative | a valid document with a version mismatch | `Version` | no | +/// +/// An earlier version of this control read a *different* case at a *different* +/// path, which is exactly the hole it was meant to close: a guard keyed on the +/// path, the extension, or the case name would have passed it. The bytes are +/// still frozen fixture bytes rather than bytes invented here — inventing them +/// would move the oracle into this file. +/// +/// The control is also deliberately *not* a malformed mutation: malformed bytes +/// could be refused by a different parser or decoder fork and "prove" the guard +/// by accident. Same argv, same path, same valid UTF-8, same fixture machinery +/// — only the content moves, from a JSON-syntax failure to a valid document +/// with a `Version` rejection. If CLI-B1 could ever match it, the boundary +/// would have widened from "the JSON parser's detail" into "strict-door wording +/// may differ", which is exactly what #261 ruling 2b refuses. +#[test] +fn cli_b1_flips_on_the_facts_bytes_and_nothing_else() { + // ONE case supplies the argv, the path and the decode route for both runs. + let case = read_json(&fixture_dir().join("refuse-json-truncated.case.json")); + assert!( + case.get("boundary").is_some(), + "the control's carrier must itself be a declared CLI-B1 case" + ); + let path = cli_b1_facts_path(&case).expect("the carrier case has a facts path"); + + // Run 1 — the carrier's own bytes: a JSON-syntax failure, so eligible. + let json_failure = std::fs::read(&path).expect("the carrier's facts are readable"); + assert_eq!( + cli_b1_eligible_for(&json_failure), + Ok(()), + "the carrier's own bytes must be CLI-B1 eligible, or the control has \ + nothing to contrast against" + ); + + // Run 2 — the SAME case, the SAME path, different bytes: a valid document + // the strict door refuses on the version gate. + let version_rejection = std::fs::read( + fixture_dir() + .join("inputs") + .join("version_mismatch.facts.json"), + ) + .expect("the frozen version-mismatch facts are readable"); + let reason = cli_b1_eligible_for(&version_rejection) + .expect_err("a Version rejection must not be CLI-B1 eligible"); + assert!( + reason.contains("Version"), + "the refusal must name the kind that disqualified it, got: {reason}" + ); + + // The two runs really did differ only in content. + assert_ne!( + json_failure, version_rejection, + "the two runs must supply different bytes" + ); + assert_eq!( + cli_b1_facts_path(&case).ok(), + Some(path), + "both runs must resolve the same path from the same case" + ); + + // And the other positives are eligible too, so the flip above is about the + // kind rather than about this one carrier being special. + for name in ["refuse-json-empty-file", "refuse-json-bom"] { + let positive = read_json(&fixture_dir().join(format!("{name}.case.json"))); + assert_eq!( + cli_b1_eligible(&positive), + Ok(()), + "{name} must be CLI-B1 eligible" + ); + } + + // The case the negative bytes came from is pinned byte-exact in its own + // right — it declares no boundary, and must not acquire one. + let pinned = read_json(&fixture_dir().join("refuse-version-mismatch.case.json")); + assert!( + pinned.get("boundary").is_none(), + "a Version rejection is pinned byte-exact (#261 ruling 2a) and must \ + carry NO boundary metadata" + ); +} + +/// Every case that carries CLI-B1 metadata really does reject with `Json`, and +/// every case that does NOT carry it is pinned byte-exact. Stated as a sweep so +/// a future case cannot acquire the relaxed matcher by accident. +#[test] +fn only_json_rejections_carry_the_declared_boundary() { + let manifest = manifest(); + let mut declared = 0_usize; + for entry in field(&manifest, "cases") + .as_array() + .expect("cases is an array") + { + let name = field(entry, "name").as_str().expect("a case name"); + let case = read_json(&fixture_dir().join(format!("{name}.case.json"))); + if case.get("boundary").is_some() { + assert_eq!( + cli_b1_eligible(&case), + Ok(()), + "{name} declares CLI-B1 but is not a Json rejection" + ); + declared = declared.saturating_add(1); + } + } + assert!( + declared > 0, + "no case declares CLI-B1 — the boundary would be untested" + ); +} + +/// `` is a licence to skip a platform's `strerror` wording, not a +/// licence to skip a line. These are the rules it is held to, asserted rather +/// than trusted — a bug here would silently weaken every refusal case. +#[test] +fn the_os_error_placeholder_is_strict_about_what_it_consumes() { + let expected = "f: error: cannot read f: \n"; + assert!(matches( + expected, + "f: error: cannot read f: [Errno 2] nope\n" + )); + assert!(matches( + expected, + "f: error: cannot read f: anything at all\n" + )); + // It must consume SOMETHING: a platform that said nothing is not a pass. + assert!(!matches(expected, "f: error: cannot read f: \n")); + // The head is byte-exact, and the tail after the line still has to match. + assert!(!matches( + expected, + "f: error: CANNOT read f: [Errno 2] nope\n" + )); + assert!(!matches( + expected, + "f: error: cannot read f: [Errno 2] nope\nextra\n" + )); + // It stops at the newline rather than swallowing the rest of the stream. + assert!(matches( + "a: \nb\n", + "a: [Errno 13] Permission denied\nb\n" + )); + // Without a placeholder the comparison is plain equality. + assert!(matches("exact\n", "exact\n")); + assert!(!matches("exact\n", "exact")); +} diff --git a/rust/crates/own-diagnostics/tests/dag.rs b/rust/crates/own-diagnostics/tests/dag.rs index e33a48d4..b4aacb26 100644 --- a/rust/crates/own-diagnostics/tests/dag.rs +++ b/rust/crates/own-diagnostics/tests/dag.rs @@ -77,6 +77,18 @@ fn allowed_edges() -> HashMap<&'static str, BTreeSet<&'static str>> { .into_iter() .collect(), ); + // The production entry point (P-022 step 7b, #261): `own-cli ownir`. It is + // the crate P-022's plan names as THE entry point, and it depends on + // exactly two workspace crates — `own-ir` for the typed strict door and + // `own-bridge` for the analysis and the byte-pinned renders. Nothing else: + // the CLI holds no analysis and no bridge logic, only argument handling, + // I/O, the display policy and the process contract, so a third edge here + // would mean logic had leaked into an argument handler. `own-codegen` in + // particular is NOT here — that is #345's `emit` slice, after #257 — and + // neither is `own-shadow`, which is #260's dev surface rather than this + // one's. Only entry-point crates may depend on `own-bridge`, and this is + // the one the constraint below names. + m.insert("own-cli", ["own-ir", "own-bridge"].into_iter().collect()); // own-analysis CONSTRUCTS diagnostics and consumes the cfg lowering. It reads // the effect type through `own_cfg::Effect`, NOT the parser — so there is no // production own-syntax edge (own-syntax is a dev-only edge for its tests). diff --git a/rust/crates/own-ir/Cargo.toml b/rust/crates/own-ir/Cargo.toml index 1a7c8d09..9909e1c1 100644 --- a/rust/crates/own-ir/Cargo.toml +++ b/rust/crates/own-ir/Cargo.toml @@ -10,6 +10,14 @@ version = "0.1.0" [dependencies] serde = { workspace = true } serde_json = { workspace = true } +# Unicode general categories, the data behind CPython's `str.isprintable()`. +# The strict door's `ownir_version` rejection interpolates a Python `repr` of +# the offending value (#261 ruling 2a: that text is ours on both sides, so a +# divergence is a bug, not a boundary), and reproducing `repr` exactly needs +# real printability rather than a guess. NOT a workspace-crate edge: `own-ir` +# is the DAG leaf and stays one — see src/pyrepr.rs for why the helper is +# carried here rather than imported. +unicode-properties = { workspace = true } [lints] workspace = true diff --git a/rust/crates/own-ir/src/lib.rs b/rust/crates/own-ir/src/lib.rs index c7776dcb..91dbee96 100644 --- a/rust/crates/own-ir/src/lib.rs +++ b/rust/crates/own-ir/src/lib.rs @@ -40,10 +40,17 @@ //! Verdict types deliberately do **not** live here: `own-ir` is facts + the //! span/location leaf; diagnostics/evidence belong to `own-diagnostics`. //! -//! Error *message* parity with Python is not claimed yet — that lands with the -//! shared error-text fixtures (P-022 oracle section), not by copy-paste. +//! Error *message* parity with Python is claimed for ONE family and not the +//! rest. #261 ruling 2a settled the `ownir_version` gate: that text is ours on +//! both sides, so a divergence there is a bug, and both its rejection messages +//! are byte-exact with the reference (see `strict::version` and `pyrepr`). +//! Every other family still carries only KIND parity — `OwnIrErrorKind` is the +//! cross-language contract and the message is a human-facing aid, which is +//! what `tests/validation_replay.rs` compares and why it says so in its own +//! docstring. pub mod protocol; +mod pyrepr; pub mod span; mod strict; @@ -617,7 +624,7 @@ impl OwnIr { "OwnIR root must be a JSON object", )); }; - strict::validate_document(obj)?; + strict::validate_document(obj, Some(text))?; // serde is the CONSTRUCTOR, not the arbiter: the document has already // been accepted, so a failure here is a hole in the validator rather // than a rejection. Marked with a sentinel the replay test asserts no @@ -651,7 +658,11 @@ impl OwnIr { "OwnIR root must be a JSON object", )); }; - strict::validate_document(obj) + // No raw text here by construction — this door's input is a value + // built in memory, so the Version message falls back to the `Value` + // spelling. Reachable only through an `extra` key colliding with the + // typed `ownir_version` field, since the field itself is `Option`. + strict::validate_document(obj, None) } /// Serialize back to a JSON value. Together with `from_json` this is the diff --git a/rust/crates/own-ir/src/pyrepr.rs b/rust/crates/own-ir/src/pyrepr.rs new file mode 100644 index 00000000..7db5458b --- /dev/null +++ b/rust/crates/own-ir/src/pyrepr.rs @@ -0,0 +1,886 @@ +//! CPython `repr()` of what CPython's `json` module read — the strict door's +//! Version message interpolates one, so byte parity needs both CPython's +//! spelling *and* CPython's reading. +//! +//! #261 ruling 2a: the `ownir_version` rejection text is **our own text on both +//! sides**, so a divergence there is a Rust bug rather than a boundary. The +//! reference writes +//! +//! ```text +//! OwnIR 'ownir_version' must be an integer, got {ver!r} +//! ``` +//! +//! and the oracle for that interpolation is **semantic, not lexical**: it is +//! `repr(json.loads(raw)["ownir_version"])`, not the raw token echoed back. The +//! two differ — `1E-6` reprs as `1e-06` and `1.00` as `1.0` — so a module that +//! echoed the source text would fail parity exactly as surely as one that +//! ignored it. +//! +//! ## Why a `serde_json::Value` is not enough (#261 R2b) +//! +//! R2 built this module over [`Value`] and read byte-parity on the ledger's +//! four controls. That measurement was real and too narrow: `Value` is a +//! **lossy** rendering of the document for `repr` purposes, in two ways no +//! single-key, integer-valued control can see. +//! +//! | what is lost | `Value` says | CPython says | +//! |---|---|---| +//! | object key order (`serde_json::Map` is a `BTreeMap`; `dict` is insertion-ordered) | `{'a': 2, 'b': 1}` | `{'b': 1, 'a': 2}` | +//! | float spelling below `1e-4` (`Display` writes ryū's shortest form; `repr` pads the exponent) | `1e-6` | `1e-06` | +//! | integer precision past `i64` (no `arbitrary_precision`, so an oversized literal arrives as `f64`) | `1e+31` | `10000000000000000000000000000000` | +//! +//! The first two are why [`PyValue`] exists and why [`version_value`] re-reads +//! the **raw text** rather than the parsed tree. Enabling +//! `serde_json/preserve_order` or `arbitrary_precision` would fix them by +//! changing `Value` for the whole workspace — a global semantic change (map +//! iteration order, number equality, `to_value` output) bought to correct one +//! error message, and #260's canonical-domain evidence is measured against the +//! current `Value`. So the re-read is scoped to the one value whose spelling is +//! contractual, on the rejection path only. +//! +//! The third is a **branch** difference rather than a spelling one, and +//! `strict::version` owns it: a Python `int` is arbitrary-precision, so an +//! oversized integral version reaches the reference's *mismatch* arm, not its +//! wrong-type arm (#261 ruling V3). +//! +//! ## What the re-read deliberately does not do +//! +//! It is not a second acceptance gate and it never widens what this crate +//! accepts. `serde_json` remains the only parser whose verdict decides +//! accept/reject; [`PyValue`] only decides how an already-certain rejection is +//! *spelled*. Two consequences are declared rather than emulated: +//! +//! * the reference's non-finite constants (`NaN`, `Infinity`) are CPython +//! `json` extensions this parser rejects as malformed — #261 ruling V1, a +//! declared reference defect, not a parity target; +//! * the literal `-0` is an `int` to CPython and an `f64` to `serde_json` — +//! #261 ruling V2, the same cross-parser encoding defect `tests/fixtures/repro` +//! froze for #260. Where the two parsers disagree about a value's *type*, the +//! re-read stands down and this crate reports what **its own** parser read, +//! because a message naming a type we did not read would be a second defect +//! dressed as a fix. That is the one `Int` case [`version_value`]'s caller +//! discards. +//! +//! ## Why this lives here, and why it is a third copy +//! +//! The same string helper exists in `own-syntax` (for `ParseError`/`LexError`) +//! and in `own-cli` (for the `--format` usage errors). `own-ir` is the DAG leaf +//! — it may depend on no workspace crate at all — so it cannot import either. A +//! shared `own-pyparity` leaf is the standing tail (see +//! `docs/notes/p022-cli-ownir.md` §6); until it exists, the alternative to this +//! copy is a Version message that is knowingly wrong, which #261 ruling 2a +//! exists to forbid. +//! +//! `unicode-properties` is added to this crate for the same reason +//! `own-syntax` carries it: `str.isprintable()` is a Unicode general-category +//! question, and guessing it would reintroduce exactly the residual divergence +//! this module is here to remove. + +// This module is prose about CPython, and `doc_markdown` reads the name as an +// un-backticked item on every mention. Backticking a proper noun mid-sentence +// twenty times reads worse than the warning it silences, so the allow is +// scoped to this file rather than the lint being weakened workspace-wide. +#![allow(clippy::doc_markdown)] + +use std::fmt::Write as _; + +use serde_json::Value; +use unicode_properties::{GeneralCategory, UnicodeGeneralCategory}; + +/// CPython `str.isprintable()` for one char: false exactly for the general +/// categories Cc, Cf, Cs, Co, Cn, Zl, Zp and Zs-other-than-space. +fn is_printable(c: char) -> bool { + if c == ' ' { + return true; + } + !matches!( + c.general_category(), + GeneralCategory::Control + | GeneralCategory::Format + | GeneralCategory::Surrogate + | GeneralCategory::PrivateUse + | GeneralCategory::Unassigned + | GeneralCategory::LineSeparator + | GeneralCategory::ParagraphSeparator + | GeneralCategory::SpaceSeparator + ) +} + +/// `repr(s)` for a string: single quotes by default, double quotes when the +/// string contains a `'` and no `"`; the backslash, the chosen quote, `\n`, +/// `\r` and `\t` escaped; every other non-printable character as +/// `\xNN` / `\uNNNN` / `\UNNNNNNNN` by code-point width; printable characters, +/// non-ASCII included, left alone. +fn py_repr_str(s: &str) -> String { + let quote = if s.contains('\'') && !s.contains('"') { + '"' + } else { + '\'' + }; + let mut out = String::with_capacity(s.len().saturating_add(2)); + out.push(quote); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c == quote => { + out.push('\\'); + out.push(c); + } + c if !is_printable(c) => { + // CPython picks the escape by code-point width. `write!` into a + // String cannot fail; the Ok(()) is discarded rather than + // unwrapped (the workspace denies `unwrap_used`). + let cp = c as u32; + if cp < 0x100 { + let _ = write!(out, "\\x{cp:02x}"); + } else if cp < 0x1_0000 { + let _ = write!(out, "\\u{cp:04x}"); + } else { + let _ = write!(out, "\\U{cp:08x}"); + } + } + c => out.push(c), + } + } + out.push(quote); + out +} + +/// `repr(f)` for a finite Python float. +/// +/// CPython's float repr is *shortest round-trip digits, then a presentation +/// rule* (`Python/pystrtod.c::format_float_short`, mode `'r'`), and both halves +/// have to be reproduced. +/// +/// ## The digits, and the tie Rust breaks the other way +/// +/// Rust's `{:e}` is also shortest-round-trip, so it agrees with CPython on the +/// *number* of digits — but not always on the last one. When a double's exact +/// value sits **exactly midway** between two candidates of that length, CPython +/// (David Gay's `dtoa`) rounds half to even and Rust's shortest formatter does +/// not: `-1128910513108089.25` is `-1128910513108089.2` to CPython and +/// `-1128910513108089.3` to `{:e}`. A 24 000-value differential sweep found 7 +/// such doubles, all of the same shape, and none of them reachable by the four +/// hand-picked controls R2 measured — which is the whole argument for sweeping. +/// +/// So the digits come from Rust's **exact** formatter asked for the shortest +/// formatter's length (`{:.*e}`), which rounds half to even. That is not a +/// second opinion about how many digits are needed: the shortest length is a +/// property of the value, and the nearest decimal of that length round-trips +/// whenever any decimal of that length does. +/// +/// ## Then the presentation rule +/// +/// * with the value written `0.d1d2…dn × 10^decpt`, exponential form is used +/// exactly when `decpt <= -4 || decpt > 16` — which is why `1e15` reprs +/// as `1000000000000000.0` and `1e16` as `1e+16`, and why `0.0001` reprs +/// in full but `1e-05` does not; +/// * the exponent is **always signed and at least two digits** (`1e+16`, +/// `1e-06`), where Rust's `{:e}` writes `1e16` and `1e-6`. That padding is +/// the whole of R2's number defect; +/// * fixed form always keeps a fractional part, so an integral float is +/// `1.0` rather than `1` (CPython's `Py_DTSF_ADD_DOT_0`). +/// +/// Non-finite inputs cannot arise from a JSON literal — #261 ruling V1 — but +/// the function is total rather than partial, because a caller that has to +/// remember a precondition is a caller that will one day forget it. +fn py_repr_float(x: f64) -> String { + if x.is_nan() { + return "nan".to_owned(); + } + if x.is_infinite() { + return if x.is_sign_negative() { + "-inf".to_owned() + } else { + "inf".to_owned() + }; + } + + // "-1.25e-7" — shortest round-trip mantissa, unpadded signed exponent. + let shortest = format!("{x:e}"); + let significant = shortest + .split_once('e') + .map_or(shortest.as_str(), |(mantissa, _)| mantissa) + .chars() + .filter(char::is_ascii_digit) + .count(); + // Re-ask at that length in EXACT mode, which rounds half to even as + // CPython does. A carry out of the leading digit (9.99…9 rounding to + // 1.0…0e+1) changes the exponent, so the exponent is read from THIS + // string rather than from the shortest one. + let rendered = format!("{x:.*e}", significant.saturating_sub(1)); + // `LowerExp` for f64 always writes an `e`; if that ever stops being true, + // the rendered form is still a truthful rendering of the value. + let Some((mantissa, exponent)) = rendered.split_once('e') else { + return rendered; + }; + let Ok(exp10) = exponent.parse::() else { + return rendered; + }; + let sign = if mantissa.starts_with('-') { "-" } else { "" }; + let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect(); + // `0.d1d2…dn × 10^decpt`, CPython's `decpt`. + let decpt = exp10.saturating_add(1); + + if decpt <= -4 || decpt > 16 { + let mut chars = digits.chars(); + let lead = chars.next().unwrap_or('0'); + let rest: String = chars.collect(); + let point = if rest.is_empty() { + String::new() + } else { + format!(".{rest}") + }; + let exp_sign = if exp10 < 0 { '-' } else { '+' }; + let magnitude = exp10.unsigned_abs(); + return format!("{sign}{lead}{point}e{exp_sign}{magnitude:02}"); + } + if decpt <= 0 { + let zeros = "0".repeat(usize::try_from(decpt.unsigned_abs()).unwrap_or(0)); + return format!("{sign}0.{zeros}{digits}"); + } + let point = usize::try_from(decpt).unwrap_or(0); + if point >= digits.len() { + let zeros = "0".repeat(point.saturating_sub(digits.len())); + return format!("{sign}{digits}{zeros}.0"); + } + match (digits.get(..point), digits.get(point..)) { + (Some(whole), Some(frac)) => format!("{sign}{whole}.{frac}"), + // Unreachable: `digits` is ASCII, so every index is a boundary. + _ => rendered, + } +} + +/// A value as CPython's `json` module decodes it. +/// +/// The three things this holds that a [`Value`] cannot: a `dict` remembers +/// **insertion order**, an `int` is **arbitrary precision** (kept as its +/// canonical decimal digits rather than narrowed to `i64`), and `int` and +/// `float` are distinguished by the *literal's* shape — `1` is an int and +/// `1.0` a float — rather than by what a numeric type could hold. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum PyValue { + None, + Bool(bool), + /// Canonical decimal digits, exactly as `str(int(token))` writes them. + Int(String), + Float(f64), + Str(String), + List(Vec), + /// Insertion-ordered, like `dict`. A repeated key keeps its **first** + /// position and takes its **last** value, which is what CPython's decoder + /// does and what `{"b": 1, "a": 2, "b": 3}` → `{'b': 3, 'a': 2}` measures. + Dict(Vec<(String, Self)>), +} + +/// `repr(value)` as CPython writes it. +/// +/// Containers recurse, because `json` decodes them to `list`/`dict` and `repr` +/// of those reprs their members: `["a"]` is `['a']`, not `["a"]`. +pub(crate) fn py_repr(value: &PyValue) -> String { + match value { + PyValue::None => "None".to_owned(), + PyValue::Bool(true) => "True".to_owned(), + PyValue::Bool(false) => "False".to_owned(), + PyValue::Int(digits) => digits.clone(), + PyValue::Float(f) => py_repr_float(*f), + PyValue::Str(s) => py_repr_str(s), + PyValue::List(items) => { + let inner: Vec = items.iter().map(py_repr).collect(); + format!("[{}]", inner.join(", ")) + } + PyValue::Dict(pairs) => { + let inner: Vec = pairs + .iter() + .map(|(k, v)| format!("{}: {}", py_repr_str(k), py_repr(v))) + .collect(); + format!("{{{}}}", inner.join(", ")) + } + } +} + +/// The lossy reading: what a [`Value`] can still say about a Python value once +/// the document text is gone. +/// +/// Used only where there is no raw text to re-read — the in-memory door +/// (`OwnIr::validate`), whose `ownir_version` is a typed `Option` and so +/// cannot reach the wrong-type message at all except through a hand-built +/// `extra` collision. Object order is `Map`'s (sorted) and an oversized integer +/// has already been flattened to `f64`; both are recorded here rather than in a +/// comment on the caller, because this is where a future reader will look. +fn from_json_value(value: &Value) -> PyValue { + match value { + Value::Null => PyValue::None, + Value::Bool(b) => PyValue::Bool(*b), + Value::Number(n) => n.as_i64().map_or_else( + || { + n.as_u64().map_or_else( + || PyValue::Float(n.as_f64().unwrap_or(f64::NAN)), + |u| PyValue::Int(u.to_string()), + ) + }, + |i| PyValue::Int(i.to_string()), + ), + Value::String(s) => PyValue::Str(s.clone()), + Value::Array(items) => PyValue::List(items.iter().map(from_json_value).collect()), + Value::Object(entries) => PyValue::Dict( + entries + .iter() + .map(|(k, v)| (k.clone(), from_json_value(v))) + .collect(), + ), + } +} + +/// `repr` of a value read from a [`Value`] — the fallback spelling. +pub(crate) fn py_repr_value(value: &Value) -> String { + py_repr(&from_json_value(value)) +} + +/// The re-read's recursion bound. `serde_json`'s own parser refuses past 128 +/// nested values, so nothing [`crate::OwnIr::from_json`] accepted can exceed +/// it; the guard exists so this reader's totality does not *depend* on that +/// remaining true. +const MAX_DEPTH: u32 = 128; + +/// Read `json.loads(text)["ownir_version"]` out of the raw document, keeping +/// what `Value` drops. +/// +/// `None` means "this reader could not answer" — a non-object root, no such +/// key, or any byte it did not expect — and the caller falls back to the +/// [`Value`] spelling. It is deliberately not an error type: this function +/// cannot make a document invalid, only better-spelled, and a reader that +/// could veto a rejection would be a second acceptance gate. +/// +/// Only the matching member is retained; every other root value is read and +/// dropped, so peak cost is one member's tree rather than the document's. +pub(crate) fn version_value(text: &str) -> Option { + let mut reader = Reader::new(text); + reader.skip_ws(); + reader.eat(b'{')?; + reader.skip_ws(); + if reader.eat(b'}').is_some() { + return None; + } + let mut found = None; + loop { + reader.skip_ws(); + let key = reader.string()?; + reader.skip_ws(); + reader.eat(b':')?; + let value = reader.value(1)?; + if key == "ownir_version" { + // Last binding wins, as CPython's decoder does for a repeated key. + found = Some(value); + } + reader.skip_ws(); + match reader.peek()? { + b',' => reader.bump(), + b'}' => return found, + _ => return None, + } + } +} + +/// A cursor over already-valid JSON text. +/// +/// Hand-rolled rather than delegated because the two things it exists to +/// preserve — member order and the exact integer token — are precisely what +/// `serde_json::Value` discards, and the features that would keep them +/// (`preserve_order`, `arbitrary_precision`) change `Value` for every crate in +/// the workspace. Every method returns `Option`, so malformed input ends the +/// read rather than panicking. +#[derive(Debug)] +struct Reader<'a> { + text: &'a str, + pos: usize, +} + +impl<'a> Reader<'a> { + const fn new(text: &'a str) -> Self { + Self { text, pos: 0 } + } + + fn peek(&self) -> Option { + self.text.as_bytes().get(self.pos).copied() + } + + fn peek_char(&self) -> Option { + self.text + .get(self.pos..) + .and_then(|rest| rest.chars().next()) + } + + fn bump(&mut self) { + self.pos = self.pos.saturating_add(1); + } + + fn skip_ws(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.bump(); + } + } + + fn eat(&mut self, byte: u8) -> Option<()> { + if self.peek() == Some(byte) { + self.bump(); + Some(()) + } else { + None + } + } + + fn word(&mut self, literal: &str) -> Option<()> { + let end = self.pos.saturating_add(literal.len()); + if self.text.get(self.pos..end) == Some(literal) { + self.pos = end; + Some(()) + } else { + None + } + } + + fn value(&mut self, depth: u32) -> Option { + if depth > MAX_DEPTH { + return None; + } + self.skip_ws(); + match self.peek()? { + b'n' => self.word("null").map(|()| PyValue::None), + b't' => self.word("true").map(|()| PyValue::Bool(true)), + b'f' => self.word("false").map(|()| PyValue::Bool(false)), + b'"' => self.string().map(PyValue::Str), + b'[' => self.list(depth), + b'{' => self.dict(depth), + b'-' | b'0'..=b'9' => self.number(), + _ => None, + } + } + + /// A number token, split the way CPython's decoder splits it: a literal + /// with a fraction or an exponent is a `float`, anything else an `int` of + /// arbitrary precision. That split is the whole of ruling V3 — `1e+31` and + /// `10000000000000000000000000000000` are the same `f64` and *different* + /// Python values. + fn number(&mut self) -> Option { + let start = self.pos; + if self.peek() == Some(b'-') { + self.bump(); + } + let digits_at = self.pos; + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.bump(); + } + if self.pos == digits_at { + return None; + } + let mut fractional = false; + if self.peek() == Some(b'.') { + fractional = true; + self.bump(); + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.bump(); + } + } + if matches!(self.peek(), Some(b'e' | b'E')) { + fractional = true; + self.bump(); + if matches!(self.peek(), Some(b'+' | b'-')) { + self.bump(); + } + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.bump(); + } + } + let token = self.text.get(start..self.pos)?; + if fractional { + token.parse::().ok().map(PyValue::Float) + } else { + Some(PyValue::Int(canonical_int(token))) + } + } + + fn string(&mut self) -> Option { + self.eat(b'"')?; + let mut out = String::new(); + loop { + let c = self.peek_char()?; + match c { + '"' => { + self.bump(); + return Some(out); + } + '\\' => { + self.bump(); + out.push(self.escape()?); + } + _ => { + self.pos = self.pos.saturating_add(c.len_utf8()); + out.push(c); + } + } + } + } + + fn escape(&mut self) -> Option { + let code = self.peek()?; + self.bump(); + let c = match code { + b'"' => '"', + b'\\' => '\\', + b'/' => '/', + b'b' => '\u{8}', + b'f' => '\u{c}', + b'n' => '\n', + b'r' => '\r', + b't' => '\t', + b'u' => return self.escaped_scalar(), + _ => return None, + }; + Some(c) + } + + /// `\uXXXX`, and the surrogate **pair** an astral scalar is written as. + fn escaped_scalar(&mut self) -> Option { + let unit = self.hex4()?; + if !(0xd800..0xdc00).contains(&unit) { + return char::from_u32(u32::from(unit)); + } + self.eat(b'\\')?; + self.eat(b'u')?; + let low = self.hex4()?; + let lead = u32::from(unit).checked_sub(0xd800)?; + let trail = u32::from(low).checked_sub(0xdc00)?; + if trail > 0x3ff { + return None; + } + let scalar = lead + .checked_mul(0x400)? + .checked_add(trail)? + .checked_add(0x1_0000)?; + char::from_u32(scalar) + } + + fn hex4(&mut self) -> Option { + let end = self.pos.saturating_add(4); + let digits = self.text.get(self.pos..end)?; + let unit = u16::from_str_radix(digits, 16).ok()?; + self.pos = end; + Some(unit) + } + + fn list(&mut self, depth: u32) -> Option { + self.eat(b'[')?; + let mut items = Vec::new(); + self.skip_ws(); + if self.eat(b']').is_some() { + return Some(PyValue::List(items)); + } + loop { + items.push(self.value(depth.saturating_add(1))?); + self.skip_ws(); + match self.peek()? { + b',' => self.bump(), + b']' => { + self.bump(); + return Some(PyValue::List(items)); + } + _ => return None, + } + } + } + + fn dict(&mut self, depth: u32) -> Option { + self.eat(b'{')?; + let mut pairs: Vec<(String, PyValue)> = Vec::new(); + self.skip_ws(); + if self.eat(b'}').is_some() { + return Some(PyValue::Dict(pairs)); + } + loop { + self.skip_ws(); + let key = self.string()?; + self.skip_ws(); + self.eat(b':')?; + let value = self.value(depth.saturating_add(1))?; + bind(&mut pairs, key, value); + self.skip_ws(); + match self.peek()? { + b',' => self.bump(), + b'}' => { + self.bump(); + return Some(PyValue::Dict(pairs)); + } + _ => return None, + } + } + } +} + +/// `d[key] = value` with `dict` semantics: a repeated key is **rebound in +/// place**, so it keeps the position of its first appearance and the value of +/// its last. +fn bind(pairs: &mut Vec<(String, PyValue)>, key: String, value: PyValue) { + if let Some(slot) = pairs.iter_mut().find(|(k, _)| *k == key) { + slot.1 = value; + } else { + pairs.push((key, value)); + } +} + +/// `str(int(token))` for a JSON integer literal. +/// +/// JSON forbids a leading zero and a leading `+`, so the literal is already +/// canonical for every value but one: `int("-0")` is `0`, and Python spells it +/// `0`. +fn canonical_int(token: &str) -> String { + let magnitude = token.strip_prefix('-').unwrap_or(token); + if !magnitude.is_empty() && magnitude.bytes().all(|b| b == b'0') { + return "0".to_owned(); + } + token.to_owned() +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::{py_repr, py_repr_float, py_repr_str, py_repr_value, version_value, PyValue}; + use serde_json::Value; + + /// `repr` of the version member, read from the RAW document — the oracle + /// the reference actually evaluates. + fn version_repr(document: &str) -> String { + py_repr(&version_value(document).expect("the reader reads a valid document")) + } + + /// The same value put through the lossy [`Value`] reading, so the tests + /// below can state *which* spelling each path produces rather than + /// asserting one and hoping the other agrees. + fn value_repr(text: &str) -> String { + let value: Value = serde_json::from_str(text).expect("valid JSON"); + py_repr_value(&value) + } + + /// The exact `ownir_version` values the validation ledger carries, against + /// CPython's `repr` as measured on this tree. These four are the reason + /// this module exists. + #[test] + fn matches_cpython_for_every_version_control() { + assert_eq!(value_repr(r#""0""#), "'0'"); + assert_eq!(value_repr("true"), "True"); + assert_eq!(value_repr("0.0"), "0.0"); + assert_eq!(value_repr("null"), "None"); + } + + /// The rest of the JSON scalar surface, and the containers `json` decodes + /// to `list`/`dict` — whose members are repr'd, not JSON-rendered. + #[test] + fn matches_cpython_for_the_rest_of_the_json_surface() { + assert_eq!(value_repr("false"), "False"); + assert_eq!(value_repr(r#""x""#), "'x'"); + assert_eq!(value_repr("5"), "5"); + assert_eq!(value_repr("-3"), "-3"); + assert_eq!(value_repr("[1]"), "[1]"); + assert_eq!(value_repr(r#"["a"]"#), "['a']"); + assert_eq!(value_repr(r#"{"a": 1}"#), "{'a': 1}"); + assert_eq!(value_repr("[true, null]"), "[True, None]"); + } + + /// CPython's quoting rules, including the quote switch. + #[test] + fn matches_cpython_quoting() { + assert_eq!(py_repr_str(""), "''"); + assert_eq!(py_repr_str("it's"), "\"it's\""); + assert_eq!(py_repr_str("say \"hi\""), "'say \"hi\"'"); + assert_eq!(py_repr_str("both ' and \""), "'both \\' and \"'"); + assert_eq!(py_repr_str("a\nb\tc\\d"), "'a\\nb\\tc\\\\d'"); + } + + /// Printability is a general-category question, which is why this crate + /// carries `unicode-properties` rather than guessing at it. + #[test] + fn matches_cpython_printability() { + assert_eq!(py_repr_str("\u{1}"), r"'\x01'"); + assert_eq!(py_repr_str("\u{a0}"), r"'\xa0'"); // Zs NBSP + assert_eq!(py_repr_str("\u{200b}"), r"'\u200b'"); // Cf + assert_eq!(py_repr_str("\u{2028}"), r"'\u2028'"); // Zl + assert_eq!(py_repr_str("\u{10ffff}"), r"'\U0010ffff'"); // Cn astral + assert_eq!(py_repr_str("\u{1f600}"), "'\u{1f600}'"); // So, printable + assert_eq!(py_repr_str("\u{e9} \u{436}"), "'\u{e9} \u{436}'"); + } + + /// CPython's float presentation rule, measured against `repr()` on this + /// tree. The two boundaries are the whole rule: `decpt > 16` at the top and + /// `decpt <= -4` at the bottom, with the exponent always signed and padded + /// to two digits. + #[test] + fn matches_cpython_float_repr() { + assert_eq!(py_repr_float(0.0), "0.0"); + assert_eq!(py_repr_float(-0.0), "-0.0"); + assert_eq!(py_repr_float(1.0), "1.0"); + assert_eq!(py_repr_float(1.5), "1.5"); + assert_eq!(py_repr_float(-2.25), "-2.25"); + assert_eq!(py_repr_float(0.1), "0.1"); + // Bottom boundary: 1e-4 is written in full, 1e-5 is not. + assert_eq!(py_repr_float(1e-4), "0.0001"); + assert_eq!(py_repr_float(1e-5), "1e-05"); + assert_eq!(py_repr_float(1e-6), "1e-06"); + assert_eq!(py_repr_float(1.5e-7), "1.5e-07"); + // Top boundary: 1e15 is written in full, 1e16 is not. + assert_eq!(py_repr_float(1e15), "1000000000000000.0"); + assert_eq!(py_repr_float(1e16), "1e+16"); + assert_eq!(py_repr_float(1.25e17), "1.25e+17"); + assert_eq!(py_repr_float(1e100), "1e+100"); + assert_eq!(py_repr_float(-1e-300), "-1e-300"); + assert_eq!(py_repr_float(123_456_789.25), "123456789.25"); + } + + /// The number defect R2 left behind, stated as the difference it was: + /// `serde_json`'s `Display` writes ryū's unpadded exponent, CPython pads it. + #[test] + fn the_exponent_padding_is_the_defect_r2_left() { + let one_millionth: Value = serde_json::from_str("1e-6").expect("valid JSON"); + assert_eq!(one_millionth.to_string(), "1e-6"); + assert_eq!(py_repr_value(&one_millionth), "1e-06"); + } + + /// A `dict` is insertion-ordered and `serde_json::Map` is a `BTreeMap`. The + /// raw re-read is the only one of the two readings that can say so — which + /// is why a single-key control could never have caught this. + #[test] + fn a_dict_keeps_document_order_not_sorted_order() { + let document = r#"{"ownir_version": {"b": 1, "a": 2}}"#; + assert_eq!(version_repr(document), "{'b': 1, 'a': 2}"); + assert_eq!(value_repr(r#"{"b": 1, "a": 2}"#), "{'a': 2, 'b': 1}"); + } + + /// A repeated key keeps its FIRST position and its LAST value. + #[test] + fn a_repeated_key_is_rebound_in_place() { + let document = r#"{"ownir_version": {"b": 1, "a": 2, "b": 3}}"#; + assert_eq!(version_repr(document), "{'b': 3, 'a': 2}"); + } + + /// Order survives nesting, and the members are repr'd all the way down. + #[test] + fn order_survives_nesting() { + let document = r#"{"ownir_version": {"z": [1, {"y": null}, true], "a": "s"}}"#; + assert_eq!( + version_repr(document), + "{'z': [1, {'y': None}, True], 'a': 's'}" + ); + } + + /// A Python `int` has no width. The reader keeps the digits; `Value` has + /// already flattened them to an `f64` by the time it is asked. + #[test] + fn an_oversized_integer_keeps_its_digits() { + let document = r#"{"ownir_version": 10000000000000000000000000000000}"#; + assert_eq!( + version_value(document), + Some(PyValue::Int("10000000000000000000000000000000".to_owned())) + ); + assert_eq!(value_repr("10000000000000000000000000000000"), "1e+31"); + } + + /// `int` versus `float` is decided by the LITERAL, not by what a number + /// type could hold: `1` is an int, `1.0` and `1e0` are floats. + #[test] + fn the_literal_decides_int_or_float() { + let int = version_value(r#"{"ownir_version": 1}"#); + assert_eq!(int, Some(PyValue::Int("1".to_owned()))); + let dotted = version_value(r#"{"ownir_version": 1.0}"#); + assert_eq!(dotted, Some(PyValue::Float(1.0))); + let exponent = version_value(r#"{"ownir_version": 1e0}"#); + assert_eq!(exponent, Some(PyValue::Float(1.0))); + // …and `repr` tells the two apart, which is the point of keeping them. + assert_eq!(version_repr(r#"{"ownir_version": 1}"#), "1"); + assert_eq!(version_repr(r#"{"ownir_version": 1.0}"#), "1.0"); + assert_eq!(version_repr(r#"{"ownir_version": 1.00}"#), "1.0"); + assert_eq!(version_repr(r#"{"ownir_version": 1E-6}"#), "1e-06"); + } + + /// `int("-0")` is `0`. This is the ONLY literal whose canonical decimal + /// differs from its token, because JSON forbids leading zeros and `+`. + #[test] + fn negative_zero_is_the_only_integer_the_token_misspells() { + assert_eq!( + version_value(r#"{"ownir_version": -0}"#), + Some(PyValue::Int("0".to_owned())) + ); + assert_eq!( + version_value(r#"{"ownir_version": -5}"#), + Some(PyValue::Int("-5".to_owned())) + ); + assert_eq!( + version_value(r#"{"ownir_version": 0}"#), + Some(PyValue::Int("0".to_owned())) + ); + } + + /// The reader is a *reader*, not a parser: whitespace, escapes and + /// surrogate pairs all have to survive it, because the value it is asked + /// about is caller-shaped. + #[test] + fn the_reader_handles_the_whole_json_string_surface() { + assert_eq!( + version_repr("{ \"ownir_version\"\n:\t\"a\\\"b\\\\c\\n\\u0001\" }"), + r#"'a"b\\c\n\x01'"# + ); + assert_eq!( + version_repr(r#"{"ownir_version": "\ud83d\ude00"}"#), + "'\u{1f600}'" + ); + assert_eq!(version_repr(r#"{"ownir_version": "\u00e9"}"#), "'\u{e9}'"); + assert_eq!( + version_repr(r#"{"ownir_version": "\/\b\f\r\t"}"#), + r"'/\x08\x0c\r\t'" + ); + assert_eq!(version_repr(r#"{"ownir_version": []}"#), "[]"); + assert_eq!(version_repr(r#"{"ownir_version": {}}"#), "{}"); + assert_eq!(version_repr(r#"{"ownir_version": [ 1 , 2 ]}"#), "[1, 2]"); + } + + /// The member is found past other members, and a repeated ROOT key takes + /// its last binding — `json.loads` does the same. + #[test] + fn the_root_member_is_found_wherever_it_sits() { + assert_eq!( + version_repr(r#"{"components": [], "ownir_version": "x"}"#), + "'x'" + ); + assert_eq!( + version_repr(r#"{"ownir_version": 1, "ownir_version": "last"}"#), + "'last'" + ); + } + + /// "Could not answer" is a first-class result: the caller falls back to the + /// `Value` spelling rather than losing the rejection. + #[test] + fn the_reader_declines_rather_than_guessing() { + assert_eq!(version_value("[]"), None); + assert_eq!(version_value("{}"), None); + assert_eq!(version_value(r#"{"other": 1}"#), None); + assert_eq!(version_value("not json at all"), None); + assert_eq!(version_value(r#"{"ownir_version": tru"#), None); + } + + /// Nesting past `serde_json`'s own parser bound cannot reach this reader + /// through `from_json`, and if it ever did the reader would decline rather + /// than recurse without limit. + #[test] + fn the_reader_declines_past_the_recursion_bound() { + let deep = format!( + "{{\"ownir_version\": {}{}}}", + "[".repeat(200), + "]".repeat(200) + ); + assert_eq!(version_value(&deep), None); + let shallow = format!("{{\"ownir_version\": {}{}}}", "[".repeat(8), "]".repeat(8)); + assert!(version_value(&shallow).is_some()); + } +} diff --git a/rust/crates/own-ir/src/strict.rs b/rust/crates/own-ir/src/strict.rs index 1fb63917..1e09360d 100644 --- a/rust/crates/own-ir/src/strict.rs +++ b/rust/crates/own-ir/src/strict.rs @@ -415,8 +415,8 @@ fn sites(obj: &Map, key: &str, what: &str) -> Checked { /// /// Sections are visited in declaration order and each is finished before the /// next begins — that is the property the cross-section ordering controls pin. -pub(crate) fn validate_document(obj: &Map) -> Checked { - version(obj)?; +pub(crate) fn validate_document(obj: &Map, source: Option<&str>) -> Checked { + version(obj, source)?; components(obj)?; services(obj)?; effects(obj)?; @@ -428,7 +428,14 @@ pub(crate) fn validate_document(obj: &Map) -> Checked { /// The version gate, first: a vocabulary mismatch makes every later shape check /// meaningless. An absent field means the current version — the only producers /// that omit it predate versioning. -fn version(obj: &Map) -> Checked { +/// +/// `source` is the raw document text when there is one ([`crate::OwnIr::from_json`]) +/// and `None` when there is not (the in-memory door, whose `ownir_version` is a +/// typed `Option`). It is read **only to spell a rejection this function +/// has already decided on**, never to decide one: `serde_json` stays the single +/// parser whose verdict is the acceptance contract, and every branch below is +/// reached on the strength of `v` alone. +fn version(obj: &Map, source: Option<&str>) -> Checked { let Some(v) = obj.get("ownir_version") else { return Ok(()); }; @@ -440,24 +447,67 @@ fn version(obj: &Map) -> Checked { let Some(ver) = ver else { return Err(OwnIrError::new( OwnIrErrorKind::Version, - format!("OwnIR 'ownir_version' must be an integer, got {v}"), + wrong_type_or_oversized(v, source), )); }; if ver != crate::OWNIR_VERSION { return Err(OwnIrError::new( OwnIrErrorKind::Version, - format!( - "OwnIR facts are schema v{ver}, but this core understands \ - v{}. Build the extractor and the core from the same commit — \ - the OwnIR fact vocabulary changed between the version that \ - produced this file and the one reading it.", - crate::OWNIR_VERSION - ), + version_mismatch(&ver.to_string()), )); } Ok(()) } +/// The message for a version `serde_json` could not read as an `i64` — which +/// is two different Python outcomes, not one. +/// +/// The reference's oracle is `repr(json.loads(raw)["ownir_version"])`, and a +/// Python `int` has no width, so an *integral* literal too large for `i64` is +/// still an `int` over there: it clears the reference's type check and lands in +/// the **mismatch** arm carrying its full decimal spelling (#261 ruling V3). +/// Anything else is genuinely the wrong type and takes the wrong-type arm. +/// +/// The one case where the raw reading is deliberately discarded is the literal +/// `-0`: an `int` to `CPython`, an `f64` to `serde_json`. Reporting it as `0` +/// would name a type this crate did not read and would imply an acceptance it +/// does not grant — #261 ruling V2, the same cross-parser encoding defect +/// `tests/fixtures/repro` froze for #260. It is recognised structurally (an +/// integral literal that *does* fit `i64`, which `serde_json` nonetheless +/// declined to read as one) rather than by matching the two characters. +fn wrong_type_or_oversized(v: &Value, source: Option<&str>) -> String { + let raw = source.and_then(crate::pyrepr::version_value); + let spelled = match &raw { + Some(crate::pyrepr::PyValue::Int(digits)) => { + if digits.parse::().is_err() { + return version_mismatch(digits); + } + // V2: the two parsers disagree about the TYPE, so this crate + // reports what its own parser read. + crate::pyrepr::py_repr_value(v) + } + Some(other) => crate::pyrepr::py_repr(other), + // No raw text, or a document this reader declined: the `Value` + // spelling is byte-exact for every scalar and differs only in object + // order and oversized integers, which is what the fallback costs. + None => crate::pyrepr::py_repr_value(v), + }; + format!("OwnIR 'ownir_version' must be an integer, got {spelled}") +} + +/// The mismatch text, shared by the two arms that can reach it so the version +/// a reader is told about is the only thing that varies between them. +fn version_mismatch(ver: &str) -> String { + format!( + "OwnIR facts are schema v{ver}, but this core understands \ + v{}. Build the Roslyn extractor and the Python core from the \ + same commit — \ + the OwnIR fact vocabulary changed between the version that \ + produced this file and the one reading it.", + crate::OWNIR_VERSION + ) +} + fn components(obj: &Map) -> Checked { let comps = objects( obj, diff --git a/rust/crates/own-ir/tests/version_repr_census.rs b/rust/crates/own-ir/tests/version_repr_census.rs new file mode 100644 index 00000000..2109f087 --- /dev/null +++ b/rust/crates/own-ir/tests/version_repr_census.rs @@ -0,0 +1,213 @@ +//! The Version family's differential census — one row per JSON value class, +//! each carrying the byte the **reference** produced for it. +//! +//! #261 ruling 2a makes the `ownir_version` rejection text our own on both +//! sides, so its oracle is `repr(json.loads(raw)["ownir_version"])` evaluated +//! by `CPython`. R2 measured four values by hand and read parity; H3.1 measured +//! the value *classes* and found three defects those four could not reach — +//! object key order, float spelling, and the branch an oversized integer takes. +//! This file is what stops that from happening a third time: the classes are +//! enumerated, and every expectation below is a `CPython` byte, not a Rust one. +//! +//! **Measured**, not asserted, on this tree with `CPython` +//! 3.11.15 (`unicodedata` 14.0.0): +//! `ownlang.ownir.load` over each document, `str(OwnIRError)` recorded +//! verbatim. It then replays with **zero Python**, which is the whole point — +//! Python authored these bytes once and the Rust suite defends them forever. +//! +//! The two groups are separate on purpose. Group 1 is the byte denominator: +//! every row must match, and a new value class belongs here unless a ruling +//! says otherwise. Group 2 holds the **declared** divergences — the reference's +//! non-standard JSON (V1), the `-0` encoding split (V2), and the Unicode +//! table skew (V4) — pinned to what THIS crate does, with the reference's own +//! answer written beside each so the difference can never quietly become a +//! match nobody re-examined, nor drift back into group 1's denominator. + +use own_ir::OwnIr; + +/// `(class, document, the reference's exact message)`. +const CENSUS: &[(&str, &str, &str)] = &[ + ("float 1e-6", "{\"ownir_version\": 1e-6}", "OwnIR 'ownir_version' must be an integer, got 1e-06"), + ("float 1E-6", "{\"ownir_version\": 1E-6}", "OwnIR 'ownir_version' must be an integer, got 1e-06"), + ("float 1e+00", "{\"ownir_version\": 1e+00}", "OwnIR 'ownir_version' must be an integer, got 1.0"), + ("float 1.00", "{\"ownir_version\": 1.00}", "OwnIR 'ownir_version' must be an integer, got 1.0"), + ("float -0.0", "{\"ownir_version\": -0.0}", "OwnIR 'ownir_version' must be an integer, got -0.0"), + ("float 1e16", "{\"ownir_version\": 1e16}", "OwnIR 'ownir_version' must be an integer, got 1e+16"), + ("float 1e15", "{\"ownir_version\": 1e15}", "OwnIR 'ownir_version' must be an integer, got 1000000000000000.0"), + ("float 1e-5", "{\"ownir_version\": 1e-5}", "OwnIR 'ownir_version' must be an integer, got 1e-05"), + ("float 1e-4", "{\"ownir_version\": 0.0001}", "OwnIR 'ownir_version' must be an integer, got 0.0001"), + // Two doubles whose exact value sits midway between two candidates of the + // shortest round-trip length. CPython rounds that tie to EVEN; Rust's + // shortest formatter does not, which is why the digits come from Rust's + // exact formatter. Found by a 24 000-value sweep, not by inspection. + ( + "float shortest-round-trip tie", + "{\"ownir_version\": -1128910513108089.2}", + "OwnIR 'ownir_version' must be an integer, got -1128910513108089.2", + ), + ( + "float tie, positive", + "{\"ownir_version\": 154463098647652.62}", + "OwnIR 'ownir_version' must be an integer, got 154463098647652.62", + ), + ("object b,a", "{\"ownir_version\": {\"b\": 1, \"a\": 2}}", "OwnIR 'ownir_version' must be an integer, got {'b': 1, 'a': 2}"), + ("object dup b", "{\"ownir_version\": {\"b\": 1, \"a\": 2, \"b\": 3}}", "OwnIR 'ownir_version' must be an integer, got {'b': 3, 'a': 2}"), + ("object nested", "{\"ownir_version\": {\"z\": [1, {\"y\": null}, true], \"a\": \"s\"}}", "OwnIR 'ownir_version' must be an integer, got {'z': [1, {'y': None}, True], 'a': 's'}"), + ("object empty", "{\"ownir_version\": {}}", "OwnIR 'ownir_version' must be an integer, got {}"), + ("string quote-heavy", "{\"ownir_version\": \"it's \\\"x\\\"\\n\\u0001\"}", "OwnIR 'ownir_version' must be an integer, got 'it\\'s \"x\"\\n\\x01'"), + ("string astral", "{\"ownir_version\": \"\\ud83d\\ude00\"}", "OwnIR 'ownir_version' must be an integer, got '😀'"), + ("string plain", "{\"ownir_version\": \"0\"}", "OwnIR 'ownir_version' must be an integer, got '0'"), + ("bool true", "{\"ownir_version\": true}", "OwnIR 'ownir_version' must be an integer, got True"), + ("null", "{\"ownir_version\": null}", "OwnIR 'ownir_version' must be an integer, got None"), + ("array of one", "{\"ownir_version\": [\"a\"]}", "OwnIR 'ownir_version' must be an integer, got ['a']"), + ("array empty", "{\"ownir_version\": []}", "OwnIR 'ownir_version' must be an integer, got []"), + ("int oversized +", "{\"ownir_version\": 10000000000000000000000000000000}", "OwnIR facts are schema v10000000000000000000000000000000, but this core understands v0. Build the Roslyn extractor and the Python core from the same commit — the OwnIR fact vocabulary changed between the version that produced this file and the one reading it."), + ("int oversized -", "{\"ownir_version\": -10000000000000000000000000000000}", "OwnIR facts are schema v-10000000000000000000000000000000, but this core understands v0. Build the Roslyn extractor and the Python core from the same commit — the OwnIR fact vocabulary changed between the version that produced this file and the one reading it."), + ("int i64 max + 1", "{\"ownir_version\": 9223372036854775808}", "OwnIR facts are schema v9223372036854775808, but this core understands v0. Build the Roslyn extractor and the Python core from the same commit — the OwnIR fact vocabulary changed between the version that produced this file and the one reading it."), + ("int mismatch 7", "{\"ownir_version\": 7}", "OwnIR facts are schema v7, but this core understands v0. Build the Roslyn extractor and the Python core from the same commit — the OwnIR fact vocabulary changed between the version that produced this file and the one reading it."), +]; + +/// Every value class the reference rejects, spelled byte for byte as the +/// reference spells it. +#[test] +fn the_version_family_is_byte_exact_with_the_reference() { + let mut divergent = Vec::new(); + for (class, document, expected) in CENSUS { + match OwnIr::from_json(document) { + Ok(_) => divergent.push(format!("{class}: accepted, reference rejected")), + Err(e) if e.message == *expected => {} + Err(e) => divergent.push(format!( + "{class}:\n reference: {expected}\n this core: {}", + e.message + )), + } + } + assert!( + divergent.is_empty(), + "{} of {} value classes diverge from the reference:\n {}", + divergent.len(), + CENSUS.len(), + divergent.join("\n ") + ); +} + +/// The census is only worth its name if it keeps covering the classes the +/// three H3.1 defects lived in. Each of these is a class a defect hid behind, +/// so deleting the row is the cheapest way to un-fix the bug. +#[test] +fn the_census_covers_the_classes_the_defects_hid_in() { + let documents: Vec<&str> = CENSUS.iter().map(|(_, d, _)| *d).collect(); + let has = |needle: &str| documents.iter().any(|d| d.contains(needle)); + // A multi-key object whose document order is NOT sorted order — the one + // shape a single-key control can never distinguish. + assert!(has(r#"{"b": 1, "a": 2}"#), "no out-of-order object class"); + // A repeated key, whose position and value come from different bindings. + assert!(has(r#""b": 1, "a": 2, "b": 3"#), "no repeated-key class"); + // A float whose exponent needs padding, on both sides of both boundaries. + for float in ["1e-6", "1e-5", "0.0001", "1e15", "1e16"] { + assert!(has(float), "no {float} class"); + } + // A float whose shortest round-trip digits are a TIE, so the last digit is + // decided by the rounding rule rather than by the value. + assert!( + has("-1128910513108089.2"), + "no shortest-round-trip tie class" + ); + // An integer past i64 in both directions, and at the exact boundary. + assert!( + has("10000000000000000000000000000000"), + "no oversized + class" + ); + assert!( + has("-10000000000000000000000000000000"), + "no oversized - class" + ); + assert!(has("9223372036854775808"), "no i64-boundary class"); +} + +/// Group 2 — the declared divergences. `(class, document)`, with the +/// reference's own answer in the comment above each row. +const DECLARED: &[(&str, &str)] = &[ + // reference: REJECT OwnIR 'ownir_version' must be an integer, got nan + ("V1 NaN", "{\"ownir_version\": NaN}"), + // reference: REJECT OwnIR 'ownir_version' must be an integer, got inf + ("V1 Infinity", "{\"ownir_version\": Infinity}"), + // reference: REJECT OwnIR 'ownir_version' must be an integer, got -inf + ("V1 -Infinity", "{\"ownir_version\": -Infinity}"), + // reference: ACCEPT + ("V2 literal -0", "{\"ownir_version\": -0}"), + // reference: REJECT OwnIR 'ownir_version' must be an integer, got '\u088f' + // (`CPython` 3.11.15 links Unicode 14.0.0, where U+088F is unassigned) + ("V4 unicode table skew", "{\"ownir_version\": \"\u{88f}\"}"), +]; + +/// The declared divergences still diverge, and still diverge the way the +/// rulings say. A ruling that has quietly become true is a ruling to retire +/// deliberately, not one to discover by accident — and one that has quietly +/// become a DIFFERENT divergence is a new defect wearing an old label. +#[test] +fn the_declared_divergences_are_exactly_the_declared_ones() { + let outcome = |document: &str| match OwnIr::from_json(document) { + Ok(_) => "ACCEPT".to_owned(), + Err(e) => e.message, + }; + let mut unhandled = Vec::new(); + for (class, document) in DECLARED { + let got = outcome(document); + match *class { + // V1: `NaN`/`Infinity` are `CPython` `json` extensions, not JSON. + // The reference reads them as floats and reports `nan`/`inf`; + // this parser refuses the document. A declared reference defect + // (#261 ruling V1) — NOT something to teach the parser. + "V1 NaN" | "V1 Infinity" | "V1 -Infinity" => assert!( + got.starts_with("not valid JSON: "), + "{class} should still be refused as malformed, got {got:?}" + ), + // V2: the reference reads the literal `-0` as the int 0 and + // ACCEPTS; `serde_json` reads the float -0.0 and this crate + // reports what its own parser read. #260 froze the same split. + "V2 literal -0" => assert_eq!( + got, "OwnIR 'ownir_version' must be an integer, got -0.0", + "{class} must report OUR parser's reading, never an acceptance \ + we do not grant" + ), + // V4: `str.isprintable()` is a question about the Unicode table + // each side was BUILT with. `CPython` 3.11.15 links 14.0.0, where + // U+088F is unassigned and so escapes; `unicode-properties` 0.1.4 + // ships 17.0.0, where it is assigned Arabic and prints. 15 097 + // code points differ across that gap (measured, whole plane sweep). + // No single static Unicode table can match every supported CPython + // reference version at once — the table is a property of the interpreter + // BUILD (CPython 3.11 -> UCD 14.0.0, CPython 3.12 -> UCD 15.0.0, + // CPython 3.13 -> UCD 15.1.0), so pinning this crate's table would buy + // parity with one Python and silently lose it against another. + "V4 unicode table skew" => assert_eq!( + got, "OwnIR 'ownir_version' must be an integer, got '\u{88f}'", + "{class} must follow THIS crate's Unicode table; if this row \ + moved, a dependency changed its answer and the skew needs \ + re-measuring, not re-asserting" + ), + other => unhandled.push(other), + } + } + assert!( + unhandled.is_empty(), + "a class was added to the declared table without a ruling to justify \ + it, so nothing checked it: {unhandled:?}" + ); +} + +/// `-0` INSIDE a container is not the V2 divergence and must not be treated as +/// one: nothing there is being type-checked, so the message is free to be the +/// reference's own spelling — and is. This row is why the V2 stand-down is +/// written as "the value the type check is about", not "any -0 anywhere". +#[test] +fn negative_zero_below_the_top_level_matches_the_reference() { + let refused = OwnIr::from_json(r#"{"ownir_version": [-0]}"#) + .err() + .map(|e| e.message); + assert_eq!( + refused.as_deref(), + Some("OwnIR 'ownir_version' must be an integer, got [0]") + ); +} diff --git a/scripts/mutate_campaign.py b/scripts/mutate_campaign.py index 4395a2ee..ad772a0a 100644 --- a/scripts/mutate_campaign.py +++ b/scripts/mutate_campaign.py @@ -737,9 +737,13 @@ def _rust_catcher_exists(target: str, test_path: str) -> str | None: The first segment may be a crate directory or a layer id (`rust-bridge` names the `own-bridge` crate), so the file is resolved by its suffix and - the ambiguity is reported rather than guessed at. A `src/lib.rs` target is - the crate's LIB target and its unit tests live wherever the module does, so - that case searches the crate's whole `src/` tree.""" + the ambiguity is reported rather than guessed at. A `src/lib.rs` or + `src/main.rs` target is the crate's LIB or BIN target, and either one's unit + tests live wherever the module does, so those cases search the crate's whole + `src/` tree. (`src/main.rs` joined `src/lib.rs` here with #261: `own-cli` is + the first binary crate whose unit tests a campaign names as catchers, and + without it every one of them read as "names a test that does not exist" + while pointing at a test that plainly did.)""" _prefix, _, rest = target.partition("/") name = test_path.rsplit("::", 1)[-1] crates = os.path.join(ROOT, "rust", "crates") @@ -752,7 +756,7 @@ def _rust_catcher_exists(target: str, test_path: str) -> str | None: return f"names no file: no rust/crates/*/{rest}" hay: list[str] = [] for match in matches: - if rest == "src/lib.rs": + if rest in ("src/lib.rs", "src/main.rs"): root = os.path.dirname(match) for dirpath, _dirs, files in os.walk(root): hay += [os.path.join(dirpath, f) for f in sorted(files) diff --git a/scripts/render_checkpoint_status.py b/scripts/render_checkpoint_status.py index d15bb22c..dcc75f43 100644 --- a/scripts/render_checkpoint_status.py +++ b/scripts/render_checkpoint_status.py @@ -70,6 +70,7 @@ from __future__ import annotations import difflib +import json import os import sys @@ -131,6 +132,8 @@ CP4B_MUTATIONS_MD = "p022-cp4b-mutations.md" COORD_MUTATIONS_MD = "p022-coord-mutations.md" MUTATIONS_MD = "p022-cp4-mutations.md" +CLI_CENSUS_MD = "p022-cli-census.md" +CLI_MUTATIONS_MD = "p022-cli-mutations.md" SHADOW_CENSUS_MD = "p022-shadow-census.md" SHADOW_MUTATIONS_MD = "p022-shadow-mutations.md" SHADOW_SWEEP_MD = "p022-shadow-sweep.md" @@ -174,6 +177,13 @@ ("the strict door — the coordinate domain, both implementations", "p022-coord-1"), ("the tolerant door — the degrade, both implementations", "p022-coord-2"), ) +# P-022 step 7b (#261): the production OwnIR executable. One campaign, because +# the surface is one process contract — the display policy, the serialization +# and the exit codes fail together and are read together. +CLI_CAMPAIGNS = ( + ("261.B — `own-cli ownir`: the display policy, the CLI's SARIF bytes, the " + "usage exit codes and the process contract", "p022-cli-1"), +) SELF = "scripts/render_checkpoint_status.py" @@ -360,6 +370,106 @@ def render_validation_census(c: ValidationCensus) -> str: lines.append("") return "\n".join(lines) +# --- the CLI contract census ---------------------------------------------- + + +class CliCensusError(Exception): + """The CLI fixture cannot be read as evidence.""" + + def __init__(self, problems: list[str]) -> None: + super().__init__("; ".join(problems)) + self.problems = problems + + +def compute_cli_census() -> tuple[list[tuple[str, int]], list[tuple[str, int]], int]: + """`(by rule, by oracle, total)` over `tests/fixtures/cli_ownir/manifest.json`. + + Read from the manifest rather than counted by hand, for the reason every + other census here exists: a number typed beside a fixture stops being true + the first time somebody adds a case and does not stop LOOKING true. + """ + path = os.path.join(ROOT, "tests", "fixtures", "cli_ownir", "manifest.json") + if not os.path.isfile(path): + raise CliCensusError([f"{_rel(path)} is missing"]) + with open(path, encoding="utf-8") as handle: + manifest = json.load(handle) + cases = manifest.get("cases") + if not isinstance(cases, list) or not cases: + raise CliCensusError([f"{_rel(path)} lists no cases"]) + by_rule: dict[str, int] = {} + by_oracle: dict[str, int] = {} + problems: list[str] = [] + for case in cases: + name = case.get("name", "?") + oracle = case.get("oracle") + if oracle not in ("python", "python-docstring", "owen-convention"): + problems.append(f"case {name!r} has an unknown oracle {oracle!r}") + continue + by_oracle[oracle] = by_oracle.get(oracle, 0) + 1 + rules = case.get("rules") + if not isinstance(rules, list) or not rules: + problems.append(f"case {name!r} names no rule it is the control for") + continue + for rule in rules: + by_rule[str(rule)] = by_rule.get(str(rule), 0) + 1 + if problems: + raise CliCensusError(problems) + return sorted(by_rule.items()), sorted(by_oracle.items()), len(cases) + + +_ORACLE_MEANING = { + "python": "an executed `python -m ownlang ownir` run", + "python-docstring": "the same, where the bytes are the WHOLE module docstring on " + "stdout — frozen as measured and flagged, so the owner can " + "declare that class a defect knowing what was frozen", + "owen-convention": "no Python byte oracle exists: the top-level shell, authored " + "once from the `owen` convention and shared with the binary", +} + + +def render_cli_census(census: tuple[list[tuple[str, int]], list[tuple[str, int]], int]) -> str: + """The frozen CLI contract, counted from the fixture that is the contract.""" + by_rule, by_oracle, total = census + lines = [ + _header("tests/fixtures/cli_ownir/manifest.json"), + "# P-022 step 7b (#261) — the `own-cli ownir` contract, counted", + "", + "`tests/fixtures/cli_ownir/` is the frozen CLI contract, authoritative via " + "`python tests/test_cli_ownir_fixtures.py --write` on Linux and replayed against " + "the built binary with **zero Python** by `own-cli/tests/replay.rs` on Linux and " + "Windows CI. Every case names the rule it is the control for and the oracle that " + "authored its bytes; both tables below are read from the manifest.", + "", + f"| {'measure'.ljust(34)} | value |", + f"|{'-' * 36}|------:|", + f"| {'frozen cases'.ljust(34)} | {total} |", + "", + "## By oracle", + "", + "The oracle boundary is #261's C-1, and it is the SURFACE rather than the " + "reference's internal print branch.", + "", + "| oracle | cases | what authored the bytes |", + "|---|------:|---|", + ] + for oracle, count in by_oracle: + lines.append(f"| `{oracle}` | {count} | {_ORACLE_MEANING[oracle]} |") + lines += [ + "", + "## By rule", + "", + "A case may be the control for more than one rule, so these do not sum to the " + "case count — they say how much evidence each rule has, which is the question.", + "", + "| rule | cases |", + "|---|------:|", + ] + for rule, count in by_rule: + lines.append(f"| `{rule}` | {count} |") + lines.append("") + return "\n".join(lines) + + # --- the coordinate census ------------------------------------------------ @@ -1057,6 +1167,27 @@ def fragments() -> tuple[dict[str, str], list[str]]: COORD_CAMPAIGNS) out[COORD_MUTATIONS_MD] = coord problems.extend(f"mutation campaign {p}" for p in coord_problems) + try: + out[CLI_CENSUS_MD] = render_cli_census(compute_cli_census()) + except CliCensusError as e: + problems.extend(f"cli contract census: {p}" for p in e.problems) + cli, cli_problems = render_campaign_set( + "# P-022 step 7b (#261) — mutation campaigns", + "The production OwnIR executable's contract: the display policy the reference's " + "`cmd_ownir` defines, the CLI's own SARIF serialization conventions (which are " + "NOT the BR-V9 fixture emitter's), the usage-error exit codes, and the process " + "contract — a catchable panic is one actionable diagnostic and exit 70, never " + "101. Every mutation is a plausible MISREADING of the reference rather than a " + "syntactic accident: each one would pass a reviewer who had read the module " + "docstring instead of the code. Every mutation edits a **production** surface " + "(P-022 discipline 2) and every declared layer runs for every mutation " + "(discipline 3: no fail-fast) — including the layer that enables the " + "off-by-default `fault-injection` feature, without which the two failure-mode " + "controls could not be seen to catch anything. The counts are derived from the " + "recorded run by `scripts/mutate_campaign.summarize()`, never typed.", + CLI_CAMPAIGNS) + out[CLI_MUTATIONS_MD] = cli + problems.extend(f"mutation campaign {p}" for p in cli_problems) return out, problems diff --git a/tests/fixtures/cli_ownir/advisory-only-normal.case.json b/tests/fixtures/cli_ownir/advisory-only-normal.case.json new file mode 100644 index 00000000..62ffee2b --- /dev/null +++ b/tests/fixtures/cli_ownir/advisory-only-normal.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_anchorless.facts.json", + "--verbosity", + "normal" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "?:0: warning: [OWN052] interprocedural summary inference failed (ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)); method summaries skipped — cross-method ownership transfer was not checked this run [resource: method summaries]\n\n0 findings, 1 advisory (OWN052).\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/advisory-only-quiet.case.json b/tests/fixtures/cli_ownir/advisory-only-quiet.case.json new file mode 100644 index 00000000..6c7255f2 --- /dev/null +++ b/tests/fixtures/cli_ownir/advisory-only-quiet.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_anchorless.facts.json", + "--verbosity", + "quiet" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "../verdict_renders/render_anchorless.facts.json: ok — no subscription leaks found\n\n0 findings (1 advisory hidden).\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/advisory-only-verbose.case.json b/tests/fixtures/cli_ownir/advisory-only-verbose.case.json new file mode 100644 index 00000000..ee6caa90 --- /dev/null +++ b/tests/fixtures/cli_ownir/advisory-only-verbose.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_anchorless.facts.json", + "--verbosity", + "verbose" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "?:0: warning: [OWN052] interprocedural summary inference failed (ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)); method summaries skipped — cross-method ownership transfer was not checked this run [resource: method summaries]\n\n0 findings, 1 advisory (OWN052).\n by code: OWN052=1\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-github-error-normal.case.json b/tests/fixtures/cli_ownir/bands-github-error-normal.case.json new file mode 100644 index 00000000..559266cd --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-github-error-normal.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "github", + "--severity", + "error", + "--verbosity", + "normal" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "::warning file=Vm.cs,line=10,title=OWN001::[OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\n::error file=Vm.cs,line=11,title=OWN001::[OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n::warning file=Vm.cs,line=13,title=OWN050::[OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-github-error-quiet.case.json b/tests/fixtures/cli_ownir/bands-github-error-quiet.case.json new file mode 100644 index 00000000..73863bd1 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-github-error-quiet.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "github", + "--severity", + "error", + "--verbosity", + "quiet" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "::warning file=Vm.cs,line=10,title=OWN001::[OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\n::error file=Vm.cs,line=11,title=OWN001::[OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n", + "stderr": "\n2 findings (1 advisory hidden), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-github-error-verbose.case.json b/tests/fixtures/cli_ownir/bands-github-error-verbose.case.json new file mode 100644 index 00000000..be9220a1 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-github-error-verbose.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "github", + "--severity", + "error", + "--verbosity", + "verbose" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "::warning file=Vm.cs,line=10,title=OWN001::[OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\n::error file=Vm.cs,line=11,title=OWN001::[OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n::warning file=Vm.cs,line=13,title=OWN050::[OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n by code: OWN001=3, OWN050=1\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-github-warning-normal.case.json b/tests/fixtures/cli_ownir/bands-github-warning-normal.case.json new file mode 100644 index 00000000..3005013d --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-github-warning-normal.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "github", + "--severity", + "warning", + "--verbosity", + "normal" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "::warning file=Vm.cs,line=10,title=OWN001::[OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\n::warning file=Vm.cs,line=11,title=OWN001::[OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n::warning file=Vm.cs,line=13,title=OWN050::[OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-github-warning-quiet.case.json b/tests/fixtures/cli_ownir/bands-github-warning-quiet.case.json new file mode 100644 index 00000000..c6e94946 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-github-warning-quiet.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "github", + "--severity", + "warning", + "--verbosity", + "quiet" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "::warning file=Vm.cs,line=10,title=OWN001::[OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\n::warning file=Vm.cs,line=11,title=OWN001::[OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n", + "stderr": "\n2 findings (1 advisory hidden), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-github-warning-verbose.case.json b/tests/fixtures/cli_ownir/bands-github-warning-verbose.case.json new file mode 100644 index 00000000..116444b4 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-github-warning-verbose.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "github", + "--severity", + "warning", + "--verbosity", + "verbose" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "::warning file=Vm.cs,line=10,title=OWN001::[OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\n::warning file=Vm.cs,line=11,title=OWN001::[OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n::warning file=Vm.cs,line=13,title=OWN050::[OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n by code: OWN001=3, OWN050=1\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-human-error-normal.case.json b/tests/fixtures/cli_ownir/bands-human-error-normal.case.json new file mode 100644 index 00000000..18a0d870 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-human-error-normal.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "human", + "--severity", + "error", + "--verbosity", + "normal" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:10: warning: [OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs:11: error: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs:13: warning: [OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-human-error-quiet.case.json b/tests/fixtures/cli_ownir/bands-human-error-quiet.case.json new file mode 100644 index 00000000..a33b4748 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-human-error-quiet.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "human", + "--severity", + "error", + "--verbosity", + "quiet" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:10: warning: [OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs:11: error: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n\n2 findings (1 advisory hidden), 1 suppressed ([OwnIgnore]).\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-human-error-verbose.case.json b/tests/fixtures/cli_ownir/bands-human-error-verbose.case.json new file mode 100644 index 00000000..2a180974 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-human-error-verbose.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "human", + "--severity", + "error", + "--verbosity", + "verbose" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:10: warning: [OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs:11: error: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs:13: warning: [OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n by code: OWN001=3, OWN050=1\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-human-warning-normal.case.json b/tests/fixtures/cli_ownir/bands-human-warning-normal.case.json new file mode 100644 index 00000000..2ee3aa38 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-human-warning-normal.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "human", + "--severity", + "warning", + "--verbosity", + "normal" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:10: warning: [OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs:11: warning: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs:13: warning: [OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-human-warning-quiet.case.json b/tests/fixtures/cli_ownir/bands-human-warning-quiet.case.json new file mode 100644 index 00000000..f1ddc09c --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-human-warning-quiet.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "human", + "--severity", + "warning", + "--verbosity", + "quiet" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:10: warning: [OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs:11: warning: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n\n2 findings (1 advisory hidden), 1 suppressed ([OwnIgnore]).\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-human-warning-verbose.case.json b/tests/fixtures/cli_ownir/bands-human-warning-verbose.case.json new file mode 100644 index 00000000..b5a44f2c --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-human-warning-verbose.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "human", + "--severity", + "warning", + "--verbosity", + "verbose" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:10: warning: [OWN001] event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs:11: warning: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs:13: warning: [OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n by code: OWN001=3, OWN050=1\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-msbuild-error-normal.case.json b/tests/fixtures/cli_ownir/bands-msbuild-error-normal.case.json new file mode 100644 index 00000000..fb8deb41 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-msbuild-error-normal.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "msbuild", + "--severity", + "error", + "--verbosity", + "normal" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs(10): warning OWN001: event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs(11): error OWN001: event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs(13): warning OWN050: cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-msbuild-error-quiet.case.json b/tests/fixtures/cli_ownir/bands-msbuild-error-quiet.case.json new file mode 100644 index 00000000..30781272 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-msbuild-error-quiet.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "msbuild", + "--severity", + "error", + "--verbosity", + "quiet" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs(10): warning OWN001: event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs(11): error OWN001: event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n", + "stderr": "\n2 findings (1 advisory hidden), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-msbuild-error-verbose.case.json b/tests/fixtures/cli_ownir/bands-msbuild-error-verbose.case.json new file mode 100644 index 00000000..bdb31722 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-msbuild-error-verbose.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "msbuild", + "--severity", + "error", + "--verbosity", + "verbose" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs(10): warning OWN001: event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs(11): error OWN001: event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs(13): warning OWN050: cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n by code: OWN001=3, OWN050=1\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-msbuild-warning-normal.case.json b/tests/fixtures/cli_ownir/bands-msbuild-warning-normal.case.json new file mode 100644 index 00000000..e12be54e --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-msbuild-warning-normal.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "msbuild", + "--severity", + "warning", + "--verbosity", + "normal" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs(10): warning OWN001: event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs(11): warning OWN001: event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs(13): warning OWN050: cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-msbuild-warning-quiet.case.json b/tests/fixtures/cli_ownir/bands-msbuild-warning-quiet.case.json new file mode 100644 index 00000000..d2fea2b6 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-msbuild-warning-quiet.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "msbuild", + "--severity", + "warning", + "--verbosity", + "quiet" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs(10): warning OWN001: event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs(11): warning OWN001: event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n", + "stderr": "\n2 findings (1 advisory hidden), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-msbuild-warning-verbose.case.json b/tests/fixtures/cli_ownir/bands-msbuild-warning-verbose.case.json new file mode 100644 index 00000000..d9569bff --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-msbuild-warning-verbose.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "msbuild", + "--severity", + "warning", + "--verbosity", + "verbose" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs(10): warning OWN001: event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\nVm.cs(11): warning OWN001: event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs(13): warning OWN050: cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n by code: OWN001=3, OWN050=1\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-sarif-error-normal.case.json b/tests/fixtures/cli_ownir/bands-sarif-error-normal.case.json new file mode 100644 index 00000000..30b980ba --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-sarif-error-normal.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "sarif", + "--severity", + "error", + "--verbosity", + "normal" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n },\n {\n \"id\": \"OWN050\",\n \"shortDescription\": {\n \"text\": \"declaring type unresolved -- leakage analysis skipped\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 10\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Injected\",\n \"handler\": \"OnI\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 11\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"SystemEvents.Static\",\n \"handler\": \"OnS\"\n }\n },\n {\n \"ruleId\": \"OWN050\",\n \"level\": \"note\",\n \"message\": {\n \"text\": \"cannot verify 'ext.Unknown' \\u2014 its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 13\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"unresolved reference\",\n \"component\": \"Vm\",\n \"event\": \"ext.Unknown\",\n \"handler\": \"OnU\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 12\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Ignored\",\n \"handler\": \"OnG\"\n },\n \"suppressions\": [\n {\n \"kind\": \"inSource\",\n \"justification\": \"owned by the host container\"\n }\n ]\n }\n ]\n }\n ]\n}\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-sarif-error-quiet.case.json b/tests/fixtures/cli_ownir/bands-sarif-error-quiet.case.json new file mode 100644 index 00000000..36a2efa1 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-sarif-error-quiet.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "sarif", + "--severity", + "error", + "--verbosity", + "quiet" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 10\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Injected\",\n \"handler\": \"OnI\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 11\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"SystemEvents.Static\",\n \"handler\": \"OnS\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 12\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Ignored\",\n \"handler\": \"OnG\"\n },\n \"suppressions\": [\n {\n \"kind\": \"inSource\",\n \"justification\": \"owned by the host container\"\n }\n ]\n }\n ]\n }\n ]\n}\n", + "stderr": "\n2 findings (1 advisory hidden), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-sarif-error-verbose.case.json b/tests/fixtures/cli_ownir/bands-sarif-error-verbose.case.json new file mode 100644 index 00000000..1c9e8fe1 --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-sarif-error-verbose.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "sarif", + "--severity", + "error", + "--verbosity", + "verbose" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n },\n {\n \"id\": \"OWN050\",\n \"shortDescription\": {\n \"text\": \"declaring type unresolved -- leakage analysis skipped\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 10\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Injected\",\n \"handler\": \"OnI\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 11\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"SystemEvents.Static\",\n \"handler\": \"OnS\"\n }\n },\n {\n \"ruleId\": \"OWN050\",\n \"level\": \"note\",\n \"message\": {\n \"text\": \"cannot verify 'ext.Unknown' \\u2014 its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 13\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"unresolved reference\",\n \"component\": \"Vm\",\n \"event\": \"ext.Unknown\",\n \"handler\": \"OnU\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 12\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Ignored\",\n \"handler\": \"OnG\"\n },\n \"suppressions\": [\n {\n \"kind\": \"inSource\",\n \"justification\": \"owned by the host container\"\n }\n ]\n }\n ]\n }\n ]\n}\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n by code: OWN001=3, OWN050=1\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-sarif-warning-normal.case.json b/tests/fixtures/cli_ownir/bands-sarif-warning-normal.case.json new file mode 100644 index 00000000..72613d4d --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-sarif-warning-normal.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "sarif", + "--severity", + "warning", + "--verbosity", + "normal" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n },\n {\n \"id\": \"OWN050\",\n \"shortDescription\": {\n \"text\": \"declaring type unresolved -- leakage analysis skipped\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 10\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Injected\",\n \"handler\": \"OnI\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 11\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"SystemEvents.Static\",\n \"handler\": \"OnS\"\n }\n },\n {\n \"ruleId\": \"OWN050\",\n \"level\": \"note\",\n \"message\": {\n \"text\": \"cannot verify 'ext.Unknown' \\u2014 its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 13\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"unresolved reference\",\n \"component\": \"Vm\",\n \"event\": \"ext.Unknown\",\n \"handler\": \"OnU\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 12\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Ignored\",\n \"handler\": \"OnG\"\n },\n \"suppressions\": [\n {\n \"kind\": \"inSource\",\n \"justification\": \"owned by the host container\"\n }\n ]\n }\n ]\n }\n ]\n}\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-sarif-warning-quiet.case.json b/tests/fixtures/cli_ownir/bands-sarif-warning-quiet.case.json new file mode 100644 index 00000000..696b01fb --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-sarif-warning-quiet.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "sarif", + "--severity", + "warning", + "--verbosity", + "quiet" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 10\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Injected\",\n \"handler\": \"OnI\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 11\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"SystemEvents.Static\",\n \"handler\": \"OnS\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 12\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Ignored\",\n \"handler\": \"OnG\"\n },\n \"suppressions\": [\n {\n \"kind\": \"inSource\",\n \"justification\": \"owned by the host container\"\n }\n ]\n }\n ]\n }\n ]\n}\n", + "stderr": "\n2 findings (1 advisory hidden), 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/bands-sarif-warning-verbose.case.json b/tests/fixtures/cli_ownir/bands-sarif-warning-verbose.case.json new file mode 100644 index 00000000..630f612e --- /dev/null +++ b/tests/fixtures/cli_ownir/bands-sarif-warning-verbose.case.json @@ -0,0 +1,21 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_tiers_and_levels.facts.json", + "--format", + "sarif", + "--severity", + "warning", + "--verbosity", + "verbose" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n },\n {\n \"id\": \"OWN050\",\n \"shortDescription\": {\n \"text\": \"declaring type unresolved -- leakage analysis skipped\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Injected' is subscribed (handler 'OnI') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 10\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Injected\",\n \"handler\": \"OnI\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 11\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"SystemEvents.Static\",\n \"handler\": \"OnS\"\n }\n },\n {\n \"ruleId\": \"OWN050\",\n \"level\": \"note\",\n \"message\": {\n \"text\": \"cannot verify 'ext.Unknown' \\u2014 its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 13\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"unresolved reference\",\n \"component\": \"Vm\",\n \"event\": \"ext.Unknown\",\n \"handler\": \"OnU\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 12\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Ignored\",\n \"handler\": \"OnG\"\n },\n \"suppressions\": [\n {\n \"kind\": \"inSource\",\n \"justification\": \"owned by the host container\"\n }\n ]\n }\n ]\n }\n ]\n}\n", + "stderr": "\n2 findings, 1 advisory (OWN050), 1 suppressed ([OwnIgnore]).\n by code: OWN001=3, OWN050=1\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/clean-github.case.json b/tests/fixtures/cli_ownir/clean-github.case.json new file mode 100644 index 00000000..0e01f82f --- /dev/null +++ b/tests/fixtures/cli_ownir/clean-github.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format", + "github" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "", + "stderr": "../verdict_renders/render_empty.facts.json: ok — no subscription leaks found\n\n0 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/clean-human.case.json b/tests/fixtures/cli_ownir/clean-human.case.json new file mode 100644 index 00000000..1c70bef1 --- /dev/null +++ b/tests/fixtures/cli_ownir/clean-human.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format", + "human" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "../verdict_renders/render_empty.facts.json: ok — no subscription leaks found\n\n0 findings.\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/clean-msbuild.case.json b/tests/fixtures/cli_ownir/clean-msbuild.case.json new file mode 100644 index 00000000..4e476fe9 --- /dev/null +++ b/tests/fixtures/cli_ownir/clean-msbuild.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format", + "msbuild" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "", + "stderr": "../verdict_renders/render_empty.facts.json: ok — no subscription leaks found\n\n0 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/clean-sarif.case.json b/tests/fixtures/cli_ownir/clean-sarif.case.json new file mode 100644 index 00000000..bd060f59 --- /dev/null +++ b/tests/fixtures/cli_ownir/clean-sarif.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format", + "sarif" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": []\n }\n ]\n}\n", + "stderr": "../verdict_renders/render_empty.facts.json: ok — no subscription leaks found\n\n0 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/cwd-relative-path-is-echoed-as-given.case.json b/tests/fixtures/cli_ownir/cwd-relative-path-is-echoed-as-given.case.json new file mode 100644 index 00000000..c94d2132 --- /dev/null +++ b/tests/fixtures/cli_ownir/cwd-relative-path-is-echoed-as-given.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "facts.json" + ], + "cwd": "inputs/pa th ünïcødé", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:3: error: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n\n1 finding.\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/debug-env-does-not-change-ordinary-output.case.json b/tests/fixtures/cli_ownir/debug-env-does-not-change-ordinary-output.case.json new file mode 100644 index 00000000..5b293723 --- /dev/null +++ b/tests/fixtures/cli_ownir/debug-env-does-not-change-ordinary-output.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_columns.facts.json" + ], + "cwd": ".", + "env": { + "OWNLANG_DEBUG": "1" + }, + "expected": { + "exit": 1, + "stdout": "Vm.cs:9: error: [OWN001] event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs:10: error: [OWN001] event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n\n2 findings.\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/empty-ignore-reason-does-not-suppress.case.json b/tests/fixtures/cli_ownir/empty-ignore-reason-does-not-suppress.case.json new file mode 100644 index 00000000..5d9d42a0 --- /dev/null +++ b/tests/fixtures/cli_ownir/empty-ignore-reason-does-not-suppress.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/empty_ignore_reason.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:5: error: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n\n1 finding.\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/inputs/empty_ignore_reason.facts.json b/tests/fixtures/cli_ownir/inputs/empty_ignore_reason.facts.json new file mode 100644 index 00000000..e09206bc --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/empty_ignore_reason.facts.json @@ -0,0 +1,25 @@ +{ + "ownir_version": 0, + "module": "EmptyReason", + "_doc": [ + "BR-V6: a reason-less [OwnIgnore] never suppresses. `ignore_reason` is", + "the empty string, which is NOT None/absent: the reference's", + "`ignore_reason is not None` is True, so the finding IS suppressed.", + "Measured, not assumed." + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + { + "event": "SystemEvents.Static", + "handler": "OnS", + "line": 5, + "source": "static", + "ignore_reason": "" + } + ] + } + ] +} diff --git a/tests/fixtures/cli_ownir/inputs/nonascii_file.facts.json b/tests/fixtures/cli_ownir/inputs/nonascii_file.facts.json new file mode 100644 index 00000000..cefc3c7a --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/nonascii_file.facts.json @@ -0,0 +1,24 @@ +{ + "ownir_version": 0, + "module": "NonAscii", + "_doc": [ + "A component whose FILE carries non-ASCII (and an astral character):", + "the CLI's SARIF path is json.dumps(ensure_ascii=True), so these become", + "\\uXXXX escapes (a surrogate PAIR above the BMP) while the human/github/", + "msbuild lines carry the characters themselves." + ], + "components": [ + { + "name": "Vm", + "file": "src/Ünïcødé/Вью_😀.cs", + "subscriptions": [ + { + "event": "SystemEvents.Static", + "handler": "OnS", + "line": 4, + "source": "static" + } + ] + } + ] +} diff --git a/tests/fixtures/cli_ownir/inputs/not_an_object.facts.json b/tests/fixtures/cli_ownir/inputs/not_an_object.facts.json new file mode 100644 index 00000000..b5d8bb58 --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/not_an_object.facts.json @@ -0,0 +1 @@ +[1, 2, 3] diff --git a/tests/fixtures/cli_ownir/inputs/not_json_bom.facts.broken b/tests/fixtures/cli_ownir/inputs/not_json_bom.facts.broken new file mode 100644 index 00000000..410b8d63 --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/not_json_bom.facts.broken @@ -0,0 +1 @@ +{"ownir_version": 0} \ No newline at end of file diff --git a/tests/fixtures/cli_ownir/inputs/not_json_empty.facts.broken b/tests/fixtures/cli_ownir/inputs/not_json_empty.facts.broken new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/cli_ownir/inputs/not_json_truncated.facts.broken b/tests/fixtures/cli_ownir/inputs/not_json_truncated.facts.broken new file mode 100644 index 00000000..e3273398 --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/not_json_truncated.facts.broken @@ -0,0 +1 @@ +{"ownir_version": 0, "components": [ \ No newline at end of file diff --git "a/tests/fixtures/cli_ownir/inputs/pa th \303\274n\303\257c\303\270d\303\251/facts.json" "b/tests/fixtures/cli_ownir/inputs/pa th \303\274n\303\257c\303\270d\303\251/facts.json" new file mode 100644 index 00000000..7f67d236 --- /dev/null +++ "b/tests/fixtures/cli_ownir/inputs/pa th \303\274n\303\257c\303\270d\303\251/facts.json" @@ -0,0 +1,22 @@ +{ + "ownir_version": 0, + "module": "SpacedPath", + "_doc": [ + "Reached through a directory whose name carries a space and non-ASCII:", + "the PATH, not the document, is the surface under test." + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + { + "event": "SystemEvents.Static", + "handler": "OnS", + "line": 3, + "source": "static" + } + ] + } + ] +} diff --git a/tests/fixtures/cli_ownir/inputs/suppressed_only.facts.json b/tests/fixtures/cli_ownir/inputs/suppressed_only.facts.json new file mode 100644 index 00000000..7ea3c91c --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/suppressed_only.facts.json @@ -0,0 +1,24 @@ +{ + "ownir_version": 0, + "module": "SuppressedOnly", + "_doc": [ + "Every finding is [OwnIgnore]-suppressed: the run prints the `ok` line", + "(nothing is shown) AND counts the suppression in the summary.", + "Trap 4 of the #261 brief, and the control for `ok-line-with-suppressed`." + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + { + "event": "SystemEvents.Static", + "handler": "OnS", + "line": 7, + "source": "static", + "ignore_reason": "owned by the host container" + } + ] + } + ] +} diff --git a/tests/fixtures/cli_ownir/inputs/version_mismatch.facts.json b/tests/fixtures/cli_ownir/inputs/version_mismatch.facts.json new file mode 100644 index 00000000..f3972b8b --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/version_mismatch.facts.json @@ -0,0 +1 @@ +{"ownir_version": 99} diff --git a/tests/fixtures/cli_ownir/inputs/version_wrong_type_bool.facts.json b/tests/fixtures/cli_ownir/inputs/version_wrong_type_bool.facts.json new file mode 100644 index 00000000..2657916c --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/version_wrong_type_bool.facts.json @@ -0,0 +1 @@ +{"ownir_version": true} diff --git a/tests/fixtures/cli_ownir/inputs/version_wrong_type_float.facts.json b/tests/fixtures/cli_ownir/inputs/version_wrong_type_float.facts.json new file mode 100644 index 00000000..924de983 --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/version_wrong_type_float.facts.json @@ -0,0 +1 @@ +{"ownir_version": 0.0} diff --git a/tests/fixtures/cli_ownir/inputs/version_wrong_type_null.facts.json b/tests/fixtures/cli_ownir/inputs/version_wrong_type_null.facts.json new file mode 100644 index 00000000..2c4b14c1 --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/version_wrong_type_null.facts.json @@ -0,0 +1 @@ +{"ownir_version": null} diff --git a/tests/fixtures/cli_ownir/inputs/version_wrong_type_string.facts.json b/tests/fixtures/cli_ownir/inputs/version_wrong_type_string.facts.json new file mode 100644 index 00000000..39d30d9d --- /dev/null +++ b/tests/fixtures/cli_ownir/inputs/version_wrong_type_string.facts.json @@ -0,0 +1 @@ +{"ownir_version": "0"} diff --git a/tests/fixtures/cli_ownir/leaky-github-error.case.json b/tests/fixtures/cli_ownir/leaky-github-error.case.json new file mode 100644 index 00000000..a0459404 --- /dev/null +++ b/tests/fixtures/cli_ownir/leaky-github-error.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_columns.facts.json", + "--format", + "github", + "--severity", + "error" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "::error file=Vm.cs,line=9,title=OWN001::[OWN001] event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n::error file=Vm.cs,line=10,title=OWN001::[OWN001] event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n", + "stderr": "\n2 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/leaky-github-warning.case.json b/tests/fixtures/cli_ownir/leaky-github-warning.case.json new file mode 100644 index 00000000..2a88ad2e --- /dev/null +++ b/tests/fixtures/cli_ownir/leaky-github-warning.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_columns.facts.json", + "--format", + "github", + "--severity", + "warning" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "::warning file=Vm.cs,line=9,title=OWN001::[OWN001] event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n::warning file=Vm.cs,line=10,title=OWN001::[OWN001] event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n", + "stderr": "\n2 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/leaky-human-error.case.json b/tests/fixtures/cli_ownir/leaky-human-error.case.json new file mode 100644 index 00000000..ef00c337 --- /dev/null +++ b/tests/fixtures/cli_ownir/leaky-human-error.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_columns.facts.json", + "--format", + "human", + "--severity", + "error" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:9: error: [OWN001] event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs:10: error: [OWN001] event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n\n2 findings.\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/leaky-human-warning.case.json b/tests/fixtures/cli_ownir/leaky-human-warning.case.json new file mode 100644 index 00000000..216aaf9e --- /dev/null +++ b/tests/fixtures/cli_ownir/leaky-human-warning.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_columns.facts.json", + "--format", + "human", + "--severity", + "warning" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:9: warning: [OWN001] event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs:10: warning: [OWN001] event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n\n2 findings.\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/leaky-msbuild-error.case.json b/tests/fixtures/cli_ownir/leaky-msbuild-error.case.json new file mode 100644 index 00000000..4c52d33f --- /dev/null +++ b/tests/fixtures/cli_ownir/leaky-msbuild-error.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_columns.facts.json", + "--format", + "msbuild", + "--severity", + "error" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs(9): error OWN001: event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs(10): error OWN001: event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n", + "stderr": "\n2 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/leaky-msbuild-warning.case.json b/tests/fixtures/cli_ownir/leaky-msbuild-warning.case.json new file mode 100644 index 00000000..916779f8 --- /dev/null +++ b/tests/fixtures/cli_ownir/leaky-msbuild-warning.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_columns.facts.json", + "--format", + "msbuild", + "--severity", + "warning" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs(9): warning OWN001: event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\nVm.cs(10): warning OWN001: event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n", + "stderr": "\n2 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/leaky-sarif-error.case.json b/tests/fixtures/cli_ownir/leaky-sarif-error.case.json new file mode 100644 index 00000000..1bc4fc99 --- /dev/null +++ b/tests/fixtures/cli_ownir/leaky-sarif-error.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_columns.facts.json", + "--format", + "sarif", + "--severity", + "error" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 9,\n \"startColumn\": 17\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Anchored\",\n \"handler\": \"OnA\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 10\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Bare\",\n \"handler\": \"OnB\"\n }\n }\n ]\n }\n ]\n}\n", + "stderr": "\n2 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/leaky-sarif-warning.case.json b/tests/fixtures/cli_ownir/leaky-sarif-warning.case.json new file mode 100644 index 00000000..d4a141c7 --- /dev/null +++ b/tests/fixtures/cli_ownir/leaky-sarif-warning.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_columns.facts.json", + "--format", + "sarif", + "--severity", + "warning" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 9,\n \"startColumn\": 17\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Anchored\",\n \"handler\": \"OnA\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"warning\",\n \"message\": {\n \"text\": \"event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 10\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Bare\",\n \"handler\": \"OnB\"\n }\n }\n ]\n }\n ]\n}\n", + "stderr": "\n2 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/manifest.json b/tests/fixtures/cli_ownir/manifest.json new file mode 100644 index 00000000..d044ebd6 --- /dev/null +++ b/tests/fixtures/cli_ownir/manifest.json @@ -0,0 +1,1028 @@ +{ + "comment": "The frozen `own-cli ownir` CLI contract (#261 261.B). Authoritative via `python tests/test_cli_ownir_fixtures.py --write` on LINUX; replayed against the built binary with zero Python by rust/crates/own-cli/tests/replay.rs on Linux and Windows. `oracle` says who authored a case's bytes: `python` an executed reference run, `python-docstring` the same where the bytes are the whole module docstring on stdout (frozen as measured AND flagged so the owner can declare that class a defect), `owen-convention` the top-level shell, which has no Python byte oracle at all.", + "cli_ownir_version": 1, + "own_cli_version": "0.1.0", + "shell_usage": "own-cli — the Own.NET core as a native executable.\n\nUsage:\n own-cli ownir [options] check OwnIR facts extracted from C#\n\nOptions (ownir):\n --format {human|github|msbuild|sarif} finding surface (default: human)\n --severity {error|warning} how a finding is shown (default: error)\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes;\n verbose adds a per-code breakdown\n (default: normal)\n --help, -h print this help\n --version print the version\n\nBoth `--flag value` and `--flag=value` are accepted. `ownir` takes exactly one\npositional argument, the facts file; there is no `--` separator and there are\nno short flags.\n\nExit codes:\n 0 clean\n 1 findings — any non-advisory, unsuppressed finding, independent of\n --severity\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", + "ownir_usage": "own-cli ownir — check OwnIR facts extracted from C# by the Roslyn frontend.\n\nUsage:\n own-cli ownir [--format F] [--severity S] [--verbosity V]\n\nOptions:\n --format {human|github|msbuild|sarif} human is the default CLI line, github\n a CI annotation, msbuild the VS Error\n List line, sarif a SARIF 2.1.0 log\n --severity {error|warning} how the host shows a finding; it never\n changes the exit code\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes\n (OWN050/051/052, OBL005); verbose adds\n a per-code breakdown over every\n finding, suppressed ones included\n\nBoth `--flag value` and `--flag=value` are accepted. Exactly one positional\nargument; there is no `--` separator and there are no short flags.\n\nExit codes:\n 0 no leaks\n 1 at least one non-advisory, unsuppressed finding\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", + "unknown_command_line": "own-cli: unknown command {name!r}\n", + "os_error_placeholder": "", + "declared_boundaries": { + "CLI-B1": { + "name": "JSON_PARSER_DETAIL", + "applies_iff": "OwnIrErrorKind == Json", + "pinned": "exit 2, stderr, kind == Json, and the full CLI-owned wrapper byte-exact: '{path}: error: {path} is not valid JSON: '", + "declared": "only the bytes AFTER that prefix — the parser library's own text", + "guard": "rust/crates/own-cli/tests/replay.rs proves valid UTF-8, then OwnIr::from_json rejecting, then kind == Json, BEFORE relaxing anything" + } + }, + "cases": [ + { + "name": "shell-empty-invocation", + "oracle": "owen-convention", + "rules": [ + "usage-owen-shape" + ], + "pins": [ + "the empty invocation prints help to stdout and exits 2" + ] + }, + { + "name": "shell-help-long", + "oracle": "owen-convention", + "rules": [ + "usage-owen-shape" + ], + "pins": [ + "--help is a success" + ] + }, + { + "name": "shell-help-short", + "oracle": "owen-convention", + "rules": [ + "usage-owen-shape" + ], + "pins": [ + "-h is the same as --help" + ] + }, + { + "name": "shell-version", + "oracle": "owen-convention", + "rules": [ + "usage-owen-shape" + ], + "pins": [ + "own-cli on stdout" + ] + }, + { + "name": "shell-unknown-command", + "oracle": "owen-convention", + "rules": [ + "usage-owen-shape" + ], + "pins": [ + "distinct from the empty invocation: one error line and the help, both on STDERR, exit 2" + ] + }, + { + "name": "shell-unknown-command-that-looks-like-a-flag", + "oracle": "owen-convention", + "rules": [ + "usage-owen-shape" + ], + "pins": [ + "an unrecognised flag at the SHELL is an unknown command; the reference's positional-swallowing parser is a branch after `ownir`, not the surface" + ] + }, + { + "name": "ownir-help-is-the-declared-defect", + "oracle": "owen-convention", + "rules": [ + "usage-owen-shape", + "declared-defect" + ], + "pins": [ + "C-1: the reference answers `cannot read --help` with exit 2. That is declared a defect and NOT ported: the shell convention extends to the subcommand" + ] + }, + { + "name": "usage-no-positional", + "oracle": "python-docstring", + "rules": [ + "docstring-on-stdout" + ], + "pins": [ + "zero positionals prints the whole module docstring to stdout, exit 2" + ] + }, + { + "name": "usage-two-positionals", + "oracle": "python-docstring", + "rules": [ + "docstring-on-stdout" + ], + "pins": [ + "two positionals: the same docstring" + ] + }, + { + "name": "usage-unknown-flag-with-path", + "oracle": "python-docstring", + "rules": [ + "docstring-on-stdout", + "unknown-flag-is-positional" + ], + "pins": [ + "an unknown flag is a POSITIONAL to the reference's parser, so with a real path it is two positionals" + ] + }, + { + "name": "usage-double-dash-not-a-separator", + "oracle": "python-docstring", + "rules": [ + "docstring-on-stdout", + "no-double-dash-separator" + ], + "pins": [ + "`--` is an ordinary positional; there is no separator" + ] + }, + { + "name": "usage-unknown-flag-alone", + "oracle": "python", + "rules": [ + "unknown-flag-is-positional", + "os-error-placeholder" + ], + "pins": [ + "one positional named --bogus: the read fails, exit 2" + ] + }, + { + "name": "usage-format-missing-value", + "oracle": "python", + "rules": [ + "missing-flag-value" + ], + "pins": [ + "`--format` with nothing after it" + ] + }, + { + "name": "usage-severity-missing-value", + "oracle": "python", + "rules": [ + "missing-flag-value" + ], + "pins": [ + "`--severity` with nothing after it" + ] + }, + { + "name": "usage-verbosity-missing-value", + "oracle": "python", + "rules": [ + "missing-flag-value" + ], + "pins": [ + "`--verbosity` with nothing after it" + ] + }, + { + "name": "usage-format-invalid", + "oracle": "python", + "rules": [ + "invalid-flag-value" + ], + "pins": [ + "the global _FORMATS gate, which still lists json" + ] + }, + { + "name": "usage-format-empty-value", + "oracle": "python", + "rules": [ + "invalid-flag-value", + "equals-spelling" + ], + "pins": [ + "`--format=` is an empty value, not a missing one" + ] + }, + { + "name": "usage-format-value-is-a-flag", + "oracle": "python", + "rules": [ + "invalid-flag-value" + ], + "pins": [ + "a following flag is consumed as the value" + ] + }, + { + "name": "usage-severity-invalid", + "oracle": "python", + "rules": [ + "invalid-flag-value" + ], + "pins": [ + "error|warning" + ] + }, + { + "name": "usage-verbosity-invalid", + "oracle": "python", + "rules": [ + "invalid-flag-value" + ], + "pins": [ + "quiet|normal|verbose" + ] + }, + { + "name": "usage-format-json-rejected-by-ownir", + "oracle": "python", + "rules": [ + "invalid-flag-value", + "ownir-format-scope" + ], + "pins": [ + "the cfg seam's format is not an ownir surface" + ] + }, + { + "name": "usage-equals-spelling-accepted", + "oracle": "python", + "rules": [ + "equals-spelling" + ], + "pins": [ + "--flag=value is identical to --flag value" + ] + }, + { + "name": "usage-duplicate-flag-last-wins", + "oracle": "python", + "rules": [ + "equals-spelling", + "duplicate-flag" + ], + "pins": [ + "a repeated flag is not an error; the last one wins" + ] + }, + { + "name": "refuse-missing-file", + "oracle": "python", + "rules": [ + "os-error-placeholder", + "strict-door" + ], + "pins": [ + "a missing facts file is a polite exit 2, never a crash" + ] + }, + { + "name": "refuse-directory", + "oracle": "python", + "rules": [ + "os-error-placeholder", + "strict-door" + ], + "pins": [ + "a directory is an OS error, and its text is platform-native" + ] + }, + { + "name": "refuse-root-not-an-object", + "oracle": "python", + "rules": [ + "strict-door" + ], + "pins": [ + "the shape refusal, byte-identical in both implementations" + ] + }, + { + "name": "refuse-unknown-flow-op", + "oracle": "python", + "rules": [ + "strict-door" + ], + "pins": [ + "the vocabulary refusal, already byte-pinned by BR-V9" + ] + }, + { + "name": "refuse-version-wrong-type-string", + "oracle": "python", + "rules": [ + "strict-door", + "version-byte-parity" + ], + "pins": [ + "a string version: repr single-quotes it, `got '0'`" + ] + }, + { + "name": "refuse-version-wrong-type-bool", + "oracle": "python", + "rules": [ + "strict-door", + "version-byte-parity" + ], + "pins": [ + "a bool version: repr capitalizes it, `got True`" + ] + }, + { + "name": "refuse-version-wrong-type-float", + "oracle": "python", + "rules": [ + "strict-door", + "version-byte-parity" + ], + "pins": [ + "a float version: the one wrong-type value that already agreed, kept so the agreement is asserted rather than assumed" + ] + }, + { + "name": "refuse-version-wrong-type-null", + "oracle": "python", + "rules": [ + "strict-door", + "version-byte-parity" + ], + "pins": [ + "a null version: repr writes `got None`, not `null` — the divergence the ruling's own list did not enumerate" + ] + }, + { + "name": "refuse-version-mismatch", + "oracle": "python", + "rules": [ + "strict-door", + "version-byte-parity", + "cli-b1-negative-control" + ], + "pins": [ + "the schema-mismatch wording, byte-exact including 'Roslyn extractor' and 'Python core'; and the negative control that CLI-B1 cannot match a non-Json rejection" + ] + }, + { + "name": "refuse-json-empty-file", + "oracle": "python", + "rules": [ + "strict-door", + "cli-b1-json-parser-detail" + ], + "pins": [ + "an empty file: the wrapper is pinned, the parser detail (CPython 'Expecting value...' vs serde 'EOF while...') is the declared tail" + ], + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } + }, + { + "name": "refuse-json-truncated", + "oracle": "python", + "rules": [ + "strict-door", + "cli-b1-json-parser-detail" + ], + "pins": [ + "a truncated document: the same wrapper, a different tail" + ], + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } + }, + { + "name": "refuse-json-bom", + "oracle": "python", + "rules": [ + "strict-door", + "cli-b1-json-parser-detail" + ], + "pins": [ + "a UTF-8 BOM: valid UTF-8, invalid JSON — it must reach CLI-B1 and not R4's invalid-UTF-8 defect" + ], + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } + }, + { + "name": "stdin-dash-is-out-of-contract", + "oracle": "python", + "rules": [ + "stdin-out-of-contract", + "os-error-placeholder" + ], + "pins": [ + "the ruling: stdin is not part of the contract. The reference opens the literal path '-' and fails; recorded here so 'out of contract' is a fixture, not a silence" + ] + }, + { + "name": "clean-human", + "oracle": "python", + "rules": [ + "ok-line", + "stream-split" + ], + "pins": [ + "a clean document rendered as human" + ] + }, + { + "name": "clean-github", + "oracle": "python", + "rules": [ + "ok-line", + "stream-split", + "machine-stdout-empty-when-clean" + ], + "pins": [ + "a clean document rendered as github" + ] + }, + { + "name": "clean-msbuild", + "oracle": "python", + "rules": [ + "ok-line", + "stream-split", + "machine-stdout-empty-when-clean" + ], + "pins": [ + "a clean document rendered as msbuild" + ] + }, + { + "name": "clean-sarif", + "oracle": "python", + "rules": [ + "ok-line", + "stream-split" + ], + "pins": [ + "a clean document rendered as sarif" + ] + }, + { + "name": "leaky-human-error", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split" + ], + "pins": [ + "two OWN001 leaks as human at --severity error" + ] + }, + { + "name": "leaky-human-warning", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split" + ], + "pins": [ + "two OWN001 leaks as human at --severity warning" + ] + }, + { + "name": "leaky-github-error", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split" + ], + "pins": [ + "two OWN001 leaks as github at --severity error" + ] + }, + { + "name": "leaky-github-warning", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split" + ], + "pins": [ + "two OWN001 leaks as github at --severity warning" + ] + }, + { + "name": "leaky-msbuild-error", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split" + ], + "pins": [ + "two OWN001 leaks as msbuild at --severity error" + ] + }, + { + "name": "leaky-msbuild-warning", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split" + ], + "pins": [ + "two OWN001 leaks as msbuild at --severity warning" + ] + }, + { + "name": "leaky-sarif-error", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split" + ], + "pins": [ + "two OWN001 leaks as sarif at --severity error" + ] + }, + { + "name": "leaky-sarif-warning", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split" + ], + "pins": [ + "two OWN001 leaks as sarif at --severity warning" + ] + }, + { + "name": "bands-human-error-quiet", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "quiet-hides-advisory" + ], + "pins": [ + "all four bands as human, --severity error, --verbosity quiet" + ] + }, + { + "name": "bands-human-error-normal", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail" + ], + "pins": [ + "all four bands as human, --severity error, --verbosity normal" + ] + }, + { + "name": "bands-human-error-verbose", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "verbose-counts-every-finding" + ], + "pins": [ + "all four bands as human, --severity error, --verbosity verbose" + ] + }, + { + "name": "bands-human-warning-quiet", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "quiet-hides-advisory" + ], + "pins": [ + "all four bands as human, --severity warning, --verbosity quiet" + ] + }, + { + "name": "bands-human-warning-normal", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail" + ], + "pins": [ + "all four bands as human, --severity warning, --verbosity normal" + ] + }, + { + "name": "bands-human-warning-verbose", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "verbose-counts-every-finding" + ], + "pins": [ + "all four bands as human, --severity warning, --verbosity verbose" + ] + }, + { + "name": "bands-github-error-quiet", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "quiet-hides-advisory" + ], + "pins": [ + "all four bands as github, --severity error, --verbosity quiet" + ] + }, + { + "name": "bands-github-error-normal", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail" + ], + "pins": [ + "all four bands as github, --severity error, --verbosity normal" + ] + }, + { + "name": "bands-github-error-verbose", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "verbose-counts-every-finding" + ], + "pins": [ + "all four bands as github, --severity error, --verbosity verbose" + ] + }, + { + "name": "bands-github-warning-quiet", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "quiet-hides-advisory" + ], + "pins": [ + "all four bands as github, --severity warning, --verbosity quiet" + ] + }, + { + "name": "bands-github-warning-normal", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail" + ], + "pins": [ + "all four bands as github, --severity warning, --verbosity normal" + ] + }, + { + "name": "bands-github-warning-verbose", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "verbose-counts-every-finding" + ], + "pins": [ + "all four bands as github, --severity warning, --verbosity verbose" + ] + }, + { + "name": "bands-msbuild-error-quiet", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "quiet-hides-advisory" + ], + "pins": [ + "all four bands as msbuild, --severity error, --verbosity quiet" + ] + }, + { + "name": "bands-msbuild-error-normal", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail" + ], + "pins": [ + "all four bands as msbuild, --severity error, --verbosity normal" + ] + }, + { + "name": "bands-msbuild-error-verbose", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "verbose-counts-every-finding" + ], + "pins": [ + "all four bands as msbuild, --severity error, --verbosity verbose" + ] + }, + { + "name": "bands-msbuild-warning-quiet", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "quiet-hides-advisory" + ], + "pins": [ + "all four bands as msbuild, --severity warning, --verbosity quiet" + ] + }, + { + "name": "bands-msbuild-warning-normal", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail" + ], + "pins": [ + "all four bands as msbuild, --severity warning, --verbosity normal" + ] + }, + { + "name": "bands-msbuild-warning-verbose", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "verbose-counts-every-finding" + ], + "pins": [ + "all four bands as msbuild, --severity warning, --verbosity verbose" + ] + }, + { + "name": "bands-sarif-error-quiet", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "quiet-hides-advisory", + "sarif-carries-shown-plus-suppressed" + ], + "pins": [ + "all four bands as sarif, --severity error, --verbosity quiet" + ] + }, + { + "name": "bands-sarif-error-normal", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "sarif-carries-shown-plus-suppressed" + ], + "pins": [ + "all four bands as sarif, --severity error, --verbosity normal" + ] + }, + { + "name": "bands-sarif-error-verbose", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "verbose-counts-every-finding", + "sarif-carries-shown-plus-suppressed" + ], + "pins": [ + "all four bands as sarif, --severity error, --verbosity verbose" + ] + }, + { + "name": "bands-sarif-warning-quiet", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "quiet-hides-advisory", + "sarif-carries-shown-plus-suppressed" + ], + "pins": [ + "all four bands as sarif, --severity warning, --verbosity quiet" + ] + }, + { + "name": "bands-sarif-warning-normal", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "sarif-carries-shown-plus-suppressed" + ], + "pins": [ + "all four bands as sarif, --severity warning, --verbosity normal" + ] + }, + { + "name": "bands-sarif-warning-verbose", + "oracle": "python", + "rules": [ + "exit-independent-of-severity", + "stream-split", + "summary-suppressed-tail", + "verbose-counts-every-finding", + "sarif-carries-shown-plus-suppressed" + ], + "pins": [ + "all four bands as sarif, --severity warning, --verbosity verbose" + ] + }, + { + "name": "advisory-only-quiet", + "oracle": "python", + "rules": [ + "advisory-never-fails-the-run", + "quiet-hides-advisory", + "verbose-counts-every-finding" + ], + "pins": [ + "an OWN052 advisory and no leak: exit 0" + ] + }, + { + "name": "advisory-only-normal", + "oracle": "python", + "rules": [ + "advisory-never-fails-the-run", + "quiet-hides-advisory", + "verbose-counts-every-finding" + ], + "pins": [ + "an OWN052 advisory and no leak: exit 0" + ] + }, + { + "name": "advisory-only-verbose", + "oracle": "python", + "rules": [ + "advisory-never-fails-the-run", + "quiet-hides-advisory", + "verbose-counts-every-finding" + ], + "pins": [ + "an OWN052 advisory and no leak: exit 0" + ] + }, + { + "name": "suppressed-only-normal", + "oracle": "python", + "rules": [ + "ok-line-with-suppressed", + "summary-suppressed-tail", + "verbose-counts-every-finding" + ], + "pins": [ + "every finding [OwnIgnore]-suppressed: ok, a tally, exit 0" + ] + }, + { + "name": "suppressed-only-verbose", + "oracle": "python", + "rules": [ + "ok-line-with-suppressed", + "summary-suppressed-tail", + "verbose-counts-every-finding" + ], + "pins": [ + "every finding [OwnIgnore]-suppressed: ok, a tally, exit 0" + ] + }, + { + "name": "suppressed-only-sarif", + "oracle": "python", + "rules": [ + "sarif-carries-shown-plus-suppressed", + "ok-line-with-suppressed" + ], + "pins": [ + "a suppressed finding still rides in the SARIF results" + ] + }, + { + "name": "empty-ignore-reason-does-not-suppress", + "oracle": "python", + "rules": [ + "empty-reason-never-suppresses" + ], + "pins": [ + "ignore_reason='' leaves the finding shown and the exit at 1" + ] + }, + { + "name": "path-nonascii-file-human", + "oracle": "python", + "rules": [ + "nonascii-in-file-field" + ], + "pins": [ + "a non-ASCII (and astral) `file` on the human line" + ] + }, + { + "name": "path-nonascii-file-github", + "oracle": "python", + "rules": [ + "nonascii-in-file-field" + ], + "pins": [ + "the same, as a GitHub annotation" + ] + }, + { + "name": "path-nonascii-file-sarif", + "oracle": "python", + "rules": [ + "sarif-ascii-escape", + "nonascii-in-file-field" + ], + "pins": [ + "json.dumps' ASCII escaping, surrogate pair above the BMP" + ] + }, + { + "name": "sarif-ascii-escape-em-dash", + "oracle": "python", + "rules": [ + "sarif-ascii-escape" + ], + "pins": [ + "the message's U+2014 leaves as the six ASCII characters \\u2014, NOT the literal bytes the BR-V9 goldens carry" + ] + }, + { + "name": "path-backslash-file-github", + "oracle": "python", + "rules": [ + "windows-path-form" + ], + "pins": [ + "a backslash `file` and the %3A/%2C property escaping" + ] + }, + { + "name": "path-backslash-file-sarif", + "oracle": "python", + "rules": [ + "windows-path-form" + ], + "pins": [ + "SARIF folds the backslash to a forward slash in the uri" + ] + }, + { + "name": "path-with-space-and-nonascii", + "oracle": "python", + "rules": [ + "nonascii-in-path" + ], + "pins": [ + "the facts PATH carries a space and non-ASCII" + ] + }, + { + "name": "debug-env-does-not-change-ordinary-output", + "oracle": "python", + "rules": [ + "debug-env-is-inert-when-nothing-crashes" + ], + "pins": [ + "OWNLANG_DEBUG only ever changes the internal-error path" + ] + }, + { + "name": "cwd-relative-path-is-echoed-as-given", + "oracle": "python", + "rules": [ + "path-echoed-as-given", + "nonascii-in-path" + ], + "pins": [ + "the summary echoes argv, never a resolved path" + ] + } + ] +} diff --git a/tests/fixtures/cli_ownir/ownir-help-is-the-declared-defect.case.json b/tests/fixtures/cli_ownir/ownir-help-is-the-declared-defect.case.json new file mode 100644 index 00000000..5437c150 --- /dev/null +++ b/tests/fixtures/cli_ownir/ownir-help-is-the-declared-defect.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "--help" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "own-cli ownir — check OwnIR facts extracted from C# by the Roslyn frontend.\n\nUsage:\n own-cli ownir [--format F] [--severity S] [--verbosity V]\n\nOptions:\n --format {human|github|msbuild|sarif} human is the default CLI line, github\n a CI annotation, msbuild the VS Error\n List line, sarif a SARIF 2.1.0 log\n --severity {error|warning} how the host shows a finding; it never\n changes the exit code\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes\n (OWN050/051/052, OBL005); verbose adds\n a per-code breakdown over every\n finding, suppressed ones included\n\nBoth `--flag value` and `--flag=value` are accepted. Exactly one positional\nargument; there is no `--` separator and there are no short flags.\n\nExit codes:\n 0 no leaks\n 1 at least one non-advisory, unsuppressed finding\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/path-backslash-file-github.case.json b/tests/fixtures/cli_ownir/path-backslash-file-github.case.json new file mode 100644 index 00000000..d8192e1e --- /dev/null +++ b/tests/fixtures/cli_ownir/path-backslash-file-github.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_escaping.facts.json", + "--format", + "github" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "::error file=src\\gen%3Aa%2Cb.cs,line=7,title=OWN001::[OWN001] event 'bus.50%25 off%0D%0Anext' is subscribed (handler 'OnE') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n", + "stderr": "\n1 finding.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/path-backslash-file-sarif.case.json b/tests/fixtures/cli_ownir/path-backslash-file-sarif.case.json new file mode 100644 index 00000000..84a6fcf3 --- /dev/null +++ b/tests/fixtures/cli_ownir/path-backslash-file-sarif.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_escaping.facts.json", + "--format", + "sarif" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'bus.50% off\\r\\nnext' is subscribed (handler 'OnE') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"src/gen:a,b.cs\"\n },\n \"region\": {\n \"startLine\": 7\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.50% off\\r\\nnext\",\n \"handler\": \"OnE\"\n }\n }\n ]\n }\n ]\n}\n", + "stderr": "\n1 finding.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/path-nonascii-file-github.case.json b/tests/fixtures/cli_ownir/path-nonascii-file-github.case.json new file mode 100644 index 00000000..41ba15fb --- /dev/null +++ b/tests/fixtures/cli_ownir/path-nonascii-file-github.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/nonascii_file.facts.json", + "--format", + "github" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "::error file=src/Ünïcødé/Вью_😀.cs,line=4,title=OWN001::[OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n", + "stderr": "\n1 finding.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/path-nonascii-file-human.case.json b/tests/fixtures/cli_ownir/path-nonascii-file-human.case.json new file mode 100644 index 00000000..2fb606f7 --- /dev/null +++ b/tests/fixtures/cli_ownir/path-nonascii-file-human.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/nonascii_file.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "src/Ünïcødé/Вью_😀.cs:4: error: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n\n1 finding.\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/path-nonascii-file-sarif.case.json b/tests/fixtures/cli_ownir/path-nonascii-file-sarif.case.json new file mode 100644 index 00000000..f9b4011a --- /dev/null +++ b/tests/fixtures/cli_ownir/path-nonascii-file-sarif.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/nonascii_file.facts.json", + "--format", + "sarif" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"src/\\u00dcn\\u00efc\\u00f8d\\u00e9/\\u0412\\u044c\\u044e_\\ud83d\\ude00.cs\"\n },\n \"region\": {\n \"startLine\": 4\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"SystemEvents.Static\",\n \"handler\": \"OnS\"\n }\n }\n ]\n }\n ]\n}\n", + "stderr": "\n1 finding.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/path-with-space-and-nonascii.case.json b/tests/fixtures/cli_ownir/path-with-space-and-nonascii.case.json new file mode 100644 index 00000000..ecceb993 --- /dev/null +++ b/tests/fixtures/cli_ownir/path-with-space-and-nonascii.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/pa th ünïcødé/facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "Vm.cs:3: error: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]\n\n1 finding.\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/refuse-directory.case.json b/tests/fixtures/cli_ownir/refuse-directory.case.json new file mode 100644 index 00000000..4272d476 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-directory.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs: error: cannot read inputs: \n", + "os_error_tail": { + "linux": "[Errno 21] Is a directory: 'inputs'" + } + } +} diff --git a/tests/fixtures/cli_ownir/refuse-json-bom.case.json b/tests/fixtures/cli_ownir/refuse-json-bom.case.json new file mode 100644 index 00000000..309b7ed2 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-json-bom.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/not_json_bom.facts.broken" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/not_json_bom.facts.broken: error: inputs/not_json_bom.facts.broken is not valid JSON: Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)\n", + "os_error_tail": null + }, + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } +} diff --git a/tests/fixtures/cli_ownir/refuse-json-empty-file.case.json b/tests/fixtures/cli_ownir/refuse-json-empty-file.case.json new file mode 100644 index 00000000..cd390904 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-json-empty-file.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/not_json_empty.facts.broken" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/not_json_empty.facts.broken: error: inputs/not_json_empty.facts.broken is not valid JSON: Expecting value: line 1 column 1 (char 0)\n", + "os_error_tail": null + }, + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } +} diff --git a/tests/fixtures/cli_ownir/refuse-json-truncated.case.json b/tests/fixtures/cli_ownir/refuse-json-truncated.case.json new file mode 100644 index 00000000..a78cef69 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-json-truncated.case.json @@ -0,0 +1,19 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/not_json_truncated.facts.broken" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/not_json_truncated.facts.broken: error: inputs/not_json_truncated.facts.broken is not valid JSON: Expecting value: line 1 column 37 (char 36)\n", + "os_error_tail": null + }, + "boundary": { + "id": "CLI-B1", + "expected_kind": "json" + } +} diff --git a/tests/fixtures/cli_ownir/refuse-missing-file.case.json b/tests/fixtures/cli_ownir/refuse-missing-file.case.json new file mode 100644 index 00000000..b00e7d7e --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-missing-file.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/no_such_facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/no_such_facts.json: error: cannot read inputs/no_such_facts.json: \n", + "os_error_tail": { + "linux": "[Errno 2] No such file or directory: 'inputs/no_such_facts.json'" + } + } +} diff --git a/tests/fixtures/cli_ownir/refuse-root-not-an-object.case.json b/tests/fixtures/cli_ownir/refuse-root-not-an-object.case.json new file mode 100644 index 00000000..96a4653d --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-root-not-an-object.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/not_an_object.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/not_an_object.facts.json: error: OwnIR root must be a JSON object\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/refuse-unknown-flow-op.case.json b/tests/fixtures/cli_ownir/refuse-unknown-flow-op.case.json new file mode 100644 index 00000000..9c56ef63 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-unknown-flow-op.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_refusal.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "../verdict_renders/render_refusal.facts.json: error: unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/refuse-version-mismatch.case.json b/tests/fixtures/cli_ownir/refuse-version-mismatch.case.json new file mode 100644 index 00000000..69bae7ac --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-version-mismatch.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/version_mismatch.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/version_mismatch.facts.json: error: OwnIR facts are schema v99, but this core understands v0. Build the Roslyn extractor and the Python core from the same commit — the OwnIR fact vocabulary changed between the version that produced this file and the one reading it.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/refuse-version-wrong-type-bool.case.json b/tests/fixtures/cli_ownir/refuse-version-wrong-type-bool.case.json new file mode 100644 index 00000000..868771d9 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-version-wrong-type-bool.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/version_wrong_type_bool.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/version_wrong_type_bool.facts.json: error: OwnIR 'ownir_version' must be an integer, got True\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/refuse-version-wrong-type-float.case.json b/tests/fixtures/cli_ownir/refuse-version-wrong-type-float.case.json new file mode 100644 index 00000000..9fab842b --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-version-wrong-type-float.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/version_wrong_type_float.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/version_wrong_type_float.facts.json: error: OwnIR 'ownir_version' must be an integer, got 0.0\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/refuse-version-wrong-type-null.case.json b/tests/fixtures/cli_ownir/refuse-version-wrong-type-null.case.json new file mode 100644 index 00000000..1a1981b7 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-version-wrong-type-null.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/version_wrong_type_null.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/version_wrong_type_null.facts.json: error: OwnIR 'ownir_version' must be an integer, got None\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/refuse-version-wrong-type-string.case.json b/tests/fixtures/cli_ownir/refuse-version-wrong-type-string.case.json new file mode 100644 index 00000000..ab3ab943 --- /dev/null +++ b/tests/fixtures/cli_ownir/refuse-version-wrong-type-string.case.json @@ -0,0 +1,15 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/version_wrong_type_string.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "inputs/version_wrong_type_string.facts.json: error: OwnIR 'ownir_version' must be an integer, got '0'\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/sarif-ascii-escape-em-dash.case.json b/tests/fixtures/cli_ownir/sarif-ascii-escape-em-dash.case.json new file mode 100644 index 00000000..5ef6ffe3 --- /dev/null +++ b/tests/fixtures/cli_ownir/sarif-ascii-escape-em-dash.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_columns.facts.json", + "--format", + "sarif" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 1, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 9,\n \"startColumn\": 17\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Anchored\",\n \"handler\": \"OnA\"\n }\n },\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 10\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"bus.Bare\",\n \"handler\": \"OnB\"\n }\n }\n ]\n }\n ]\n}\n", + "stderr": "\n2 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/shell-empty-invocation.case.json b/tests/fixtures/cli_ownir/shell-empty-invocation.case.json new file mode 100644 index 00000000..4acec51f --- /dev/null +++ b/tests/fixtures/cli_ownir/shell-empty-invocation.case.json @@ -0,0 +1,12 @@ +{ + "cli_ownir_version": 1, + "argv": [], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "own-cli — the Own.NET core as a native executable.\n\nUsage:\n own-cli ownir [options] check OwnIR facts extracted from C#\n\nOptions (ownir):\n --format {human|github|msbuild|sarif} finding surface (default: human)\n --severity {error|warning} how a finding is shown (default: error)\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes;\n verbose adds a per-code breakdown\n (default: normal)\n --help, -h print this help\n --version print the version\n\nBoth `--flag value` and `--flag=value` are accepted. `ownir` takes exactly one\npositional argument, the facts file; there is no `--` separator and there are\nno short flags.\n\nExit codes:\n 0 clean\n 1 findings — any non-advisory, unsuppressed finding, independent of\n --severity\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/shell-help-long.case.json b/tests/fixtures/cli_ownir/shell-help-long.case.json new file mode 100644 index 00000000..9c054673 --- /dev/null +++ b/tests/fixtures/cli_ownir/shell-help-long.case.json @@ -0,0 +1,14 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "--help" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "own-cli — the Own.NET core as a native executable.\n\nUsage:\n own-cli ownir [options] check OwnIR facts extracted from C#\n\nOptions (ownir):\n --format {human|github|msbuild|sarif} finding surface (default: human)\n --severity {error|warning} how a finding is shown (default: error)\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes;\n verbose adds a per-code breakdown\n (default: normal)\n --help, -h print this help\n --version print the version\n\nBoth `--flag value` and `--flag=value` are accepted. `ownir` takes exactly one\npositional argument, the facts file; there is no `--` separator and there are\nno short flags.\n\nExit codes:\n 0 clean\n 1 findings — any non-advisory, unsuppressed finding, independent of\n --severity\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/shell-help-short.case.json b/tests/fixtures/cli_ownir/shell-help-short.case.json new file mode 100644 index 00000000..7789de18 --- /dev/null +++ b/tests/fixtures/cli_ownir/shell-help-short.case.json @@ -0,0 +1,14 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "-h" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "own-cli — the Own.NET core as a native executable.\n\nUsage:\n own-cli ownir [options] check OwnIR facts extracted from C#\n\nOptions (ownir):\n --format {human|github|msbuild|sarif} finding surface (default: human)\n --severity {error|warning} how a finding is shown (default: error)\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes;\n verbose adds a per-code breakdown\n (default: normal)\n --help, -h print this help\n --version print the version\n\nBoth `--flag value` and `--flag=value` are accepted. `ownir` takes exactly one\npositional argument, the facts file; there is no `--` separator and there are\nno short flags.\n\nExit codes:\n 0 clean\n 1 findings — any non-advisory, unsuppressed finding, independent of\n --severity\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/shell-unknown-command-that-looks-like-a-flag.case.json b/tests/fixtures/cli_ownir/shell-unknown-command-that-looks-like-a-flag.case.json new file mode 100644 index 00000000..67369c10 --- /dev/null +++ b/tests/fixtures/cli_ownir/shell-unknown-command-that-looks-like-a-flag.case.json @@ -0,0 +1,14 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "--nope" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "own-cli: unknown command '--nope'\nown-cli — the Own.NET core as a native executable.\n\nUsage:\n own-cli ownir [options] check OwnIR facts extracted from C#\n\nOptions (ownir):\n --format {human|github|msbuild|sarif} finding surface (default: human)\n --severity {error|warning} how a finding is shown (default: error)\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes;\n verbose adds a per-code breakdown\n (default: normal)\n --help, -h print this help\n --version print the version\n\nBoth `--flag value` and `--flag=value` are accepted. `ownir` takes exactly one\npositional argument, the facts file; there is no `--` separator and there are\nno short flags.\n\nExit codes:\n 0 clean\n 1 findings — any non-advisory, unsuppressed finding, independent of\n --severity\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/shell-unknown-command.case.json b/tests/fixtures/cli_ownir/shell-unknown-command.case.json new file mode 100644 index 00000000..a989ec87 --- /dev/null +++ b/tests/fixtures/cli_ownir/shell-unknown-command.case.json @@ -0,0 +1,14 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "bogus" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "own-cli: unknown command 'bogus'\nown-cli — the Own.NET core as a native executable.\n\nUsage:\n own-cli ownir [options] check OwnIR facts extracted from C#\n\nOptions (ownir):\n --format {human|github|msbuild|sarif} finding surface (default: human)\n --severity {error|warning} how a finding is shown (default: error)\n --verbosity {quiet|normal|verbose} quiet hides the advisory notes;\n verbose adds a per-code breakdown\n (default: normal)\n --help, -h print this help\n --version print the version\n\nBoth `--flag value` and `--flag=value` are accepted. `ownir` takes exactly one\npositional argument, the facts file; there is no `--` separator and there are\nno short flags.\n\nExit codes:\n 0 clean\n 1 findings — any non-advisory, unsuppressed finding, independent of\n --severity\n 2 usage error, or a facts document the strict door refuses\n 70 internal error — a bug in the analyzer, never silence\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/shell-version.case.json b/tests/fixtures/cli_ownir/shell-version.case.json new file mode 100644 index 00000000..95df28cf --- /dev/null +++ b/tests/fixtures/cli_ownir/shell-version.case.json @@ -0,0 +1,14 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "--version" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "own-cli 0.1.0\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/stdin-dash-is-out-of-contract.case.json b/tests/fixtures/cli_ownir/stdin-dash-is-out-of-contract.case.json new file mode 100644 index 00000000..0dd8811f --- /dev/null +++ b/tests/fixtures/cli_ownir/stdin-dash-is-out-of-contract.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "-" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "-: error: cannot read -: \n", + "os_error_tail": { + "linux": "[Errno 2] No such file or directory: '-'" + } + } +} diff --git a/tests/fixtures/cli_ownir/suppressed-only-normal.case.json b/tests/fixtures/cli_ownir/suppressed-only-normal.case.json new file mode 100644 index 00000000..63057458 --- /dev/null +++ b/tests/fixtures/cli_ownir/suppressed-only-normal.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/suppressed_only.facts.json", + "--verbosity", + "normal" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "inputs/suppressed_only.facts.json: ok — no subscription leaks found\n\n0 findings, 1 suppressed ([OwnIgnore]).\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/suppressed-only-sarif.case.json b/tests/fixtures/cli_ownir/suppressed-only-sarif.case.json new file mode 100644 index 00000000..03adfc2c --- /dev/null +++ b/tests/fixtures/cli_ownir/suppressed-only-sarif.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/suppressed_only.facts.json", + "--format", + "sarif" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [\n {\n \"id\": \"OWN001\",\n \"shortDescription\": {\n \"text\": \"owned resource not released on all paths (possible leak)\"\n }\n }\n ],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": [\n {\n \"ruleId\": \"OWN001\",\n \"level\": \"error\",\n \"message\": {\n \"text\": \"event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed \\u2014 the source keeps 'Vm' alive (leak) [resource: subscription token]\"\n },\n \"locations\": [\n {\n \"physicalLocation\": {\n \"artifactLocation\": {\n \"uri\": \"Vm.cs\"\n },\n \"region\": {\n \"startLine\": 7\n }\n }\n }\n ],\n \"properties\": {\n \"resourceKind\": \"subscription token\",\n \"component\": \"Vm\",\n \"event\": \"SystemEvents.Static\",\n \"handler\": \"OnS\"\n },\n \"suppressions\": [\n {\n \"kind\": \"inSource\",\n \"justification\": \"owned by the host container\"\n }\n ]\n }\n ]\n }\n ]\n}\n", + "stderr": "inputs/suppressed_only.facts.json: ok — no subscription leaks found\n\n0 findings, 1 suppressed ([OwnIgnore]).\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/suppressed-only-verbose.case.json b/tests/fixtures/cli_ownir/suppressed-only-verbose.case.json new file mode 100644 index 00000000..e13cfa71 --- /dev/null +++ b/tests/fixtures/cli_ownir/suppressed-only-verbose.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "inputs/suppressed_only.facts.json", + "--verbosity", + "verbose" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "inputs/suppressed_only.facts.json: ok — no subscription leaks found\n\n0 findings, 1 suppressed ([OwnIgnore]).\n by code: OWN001=1\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-double-dash-not-a-separator.case.json b/tests/fixtures/cli_ownir/usage-double-dash-not-a-separator.case.json new file mode 100644 index 00000000..87e7ce34 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-double-dash-not-a-separator.case.json @@ -0,0 +1,16 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "--", + "../verdict_renders/render_empty.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\"), `normal` (default), or `verbose` (also print a\nper-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-duplicate-flag-last-wins.case.json b/tests/fixtures/cli_ownir/usage-duplicate-flag-last-wins.case.json new file mode 100644 index 00000000..9b003cdb --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-duplicate-flag-last-wins.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format=human", + "--format=sarif" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": []\n }\n ]\n}\n", + "stderr": "../verdict_renders/render_empty.facts.json: ok — no subscription leaks found\n\n0 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-equals-spelling-accepted.case.json b/tests/fixtures/cli_ownir/usage-equals-spelling-accepted.case.json new file mode 100644 index 00000000..9afa2ec3 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-equals-spelling-accepted.case.json @@ -0,0 +1,16 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format=sarif" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 0, + "stdout": "{\n \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\",\n \"version\": \"2.1.0\",\n \"runs\": [\n {\n \"tool\": {\n \"driver\": {\n \"name\": \"Owen\",\n \"informationUri\": \"https://github.com/PhysShell/Own.NET\",\n \"rules\": [],\n \"properties\": {\n \"ownirSchemaVersion\": 0\n }\n }\n },\n \"results\": []\n }\n ]\n}\n", + "stderr": "../verdict_renders/render_empty.facts.json: ok — no subscription leaks found\n\n0 findings.\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-format-empty-value.case.json b/tests/fixtures/cli_ownir/usage-format-empty-value.case.json new file mode 100644 index 00000000..f95d98d0 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-format-empty-value.case.json @@ -0,0 +1,16 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format=" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "unknown --format '' (choose: github, human, json, msbuild, sarif)\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-format-invalid.case.json b/tests/fixtures/cli_ownir/usage-format-invalid.case.json new file mode 100644 index 00000000..4239ac78 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-format-invalid.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format", + "x" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "unknown --format 'x' (choose: github, human, json, msbuild, sarif)\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-format-json-rejected-by-ownir.case.json b/tests/fixtures/cli_ownir/usage-format-json-rejected-by-ownir.case.json new file mode 100644 index 00000000..b566e31f --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-format-json-rejected-by-ownir.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format", + "json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "ownir --format must be one of github/human/msbuild/sarif (got 'json')\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-format-missing-value.case.json b/tests/fixtures/cli_ownir/usage-format-missing-value.case.json new file mode 100644 index 00000000..ca9fe284 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-format-missing-value.case.json @@ -0,0 +1,16 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "--format requires a value\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-format-value-is-a-flag.case.json b/tests/fixtures/cli_ownir/usage-format-value-is-a-flag.case.json new file mode 100644 index 00000000..4e1dce46 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-format-value-is-a-flag.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--format", + "--severity" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "unknown --format '--severity' (choose: github, human, json, msbuild, sarif)\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-no-positional.case.json b/tests/fixtures/cli_ownir/usage-no-positional.case.json new file mode 100644 index 00000000..963beddf --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-no-positional.case.json @@ -0,0 +1,14 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\"), `normal` (default), or `verbose` (also print a\nper-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-severity-invalid.case.json b/tests/fixtures/cli_ownir/usage-severity-invalid.case.json new file mode 100644 index 00000000..cf44dd48 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-severity-invalid.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--severity", + "x" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "unknown --severity 'x' (choose: error, warning)\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-severity-missing-value.case.json b/tests/fixtures/cli_ownir/usage-severity-missing-value.case.json new file mode 100644 index 00000000..0e135565 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-severity-missing-value.case.json @@ -0,0 +1,16 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--severity" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "--severity requires a value\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-two-positionals.case.json b/tests/fixtures/cli_ownir/usage-two-positionals.case.json new file mode 100644 index 00000000..933dbf59 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-two-positionals.case.json @@ -0,0 +1,16 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "../verdict_renders/render_empty.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\"), `normal` (default), or `verbose` (also print a\nper-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-unknown-flag-alone.case.json b/tests/fixtures/cli_ownir/usage-unknown-flag-alone.case.json new file mode 100644 index 00000000..b6917ff1 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-unknown-flag-alone.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "--bogus" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "--bogus: error: cannot read --bogus: \n", + "os_error_tail": { + "linux": "[Errno 2] No such file or directory: '--bogus'" + } + } +} diff --git a/tests/fixtures/cli_ownir/usage-unknown-flag-with-path.case.json b/tests/fixtures/cli_ownir/usage-unknown-flag-with-path.case.json new file mode 100644 index 00000000..6eadc5c6 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-unknown-flag-with-path.case.json @@ -0,0 +1,16 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "--bogus", + "../verdict_renders/render_empty.facts.json" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "\nCommand-line driver for the OwnLang PoC.\n\n python -m ownlang check file.own # report ownership diagnostics\n python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)\n python -m ownlang emit file.own # check, then print generated C#\n python -m ownlang cfg file.own # dump the control-flow graph (human debug view)\n python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)\n python -m ownlang report file.own # buffer storage report + .ownreport.json\n python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)\n python -m ownlang ownir facts.json --format github|msbuild|human|sarif\n python -m ownlang summaries facts.json # dump solved method-ownership summaries\n # (MOS) + extern log — deterministic JSON\n python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix\n python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file\n\n`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the\nroslyn-tools-shaped surface advertises): it prints what a code means, why it fires,\nand how to fix it. It lives in the core, next to the catalogue, because there is one\nchecker — the C# extractor emits facts, it does not own the diagnostics.\n\n`--format` selects the finding surface. On `ownir`: `human` (default CLI line),\n`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`\n(a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too).\nOn `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a\nSARIF log carrying each finding's evidence slice (relatedLocations / codeFlows);\n`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic).\n`--severity` (ownir only) picks how the host shows a finding — `error` (default,\nfails a build / red check) or `warning` (advisory). It is a presentation choice;\nthe finding is still the core's verdict.\n`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory notes:\nOWN050 \"leakage analysis skipped\", OWN051 \"ownership transfer unverified\",\nOWN052 \"summaries skipped\"), `normal` (default), or `verbose` (also print a\nper-code breakdown).\n\nExit code is non-zero if any error-level diagnostic was produced.\n\n", + "stderr": "", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-verbosity-invalid.case.json b/tests/fixtures/cli_ownir/usage-verbosity-invalid.case.json new file mode 100644 index 00000000..13255b83 --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-verbosity-invalid.case.json @@ -0,0 +1,17 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--verbosity", + "x" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "unknown --verbosity 'x' (choose: normal, quiet, verbose)\n", + "os_error_tail": null + } +} diff --git a/tests/fixtures/cli_ownir/usage-verbosity-missing-value.case.json b/tests/fixtures/cli_ownir/usage-verbosity-missing-value.case.json new file mode 100644 index 00000000..7ea9a94a --- /dev/null +++ b/tests/fixtures/cli_ownir/usage-verbosity-missing-value.case.json @@ -0,0 +1,16 @@ +{ + "cli_ownir_version": 1, + "argv": [ + "ownir", + "../verdict_renders/render_empty.facts.json", + "--verbosity" + ], + "cwd": ".", + "env": {}, + "expected": { + "exit": 2, + "stdout": "", + "stderr": "--verbosity requires a value\n", + "os_error_tail": null + } +} diff --git a/tests/test_checkpoint_status.py b/tests/test_checkpoint_status.py index b29d7b9e..941b2473 100644 --- a/tests/test_checkpoint_status.py +++ b/tests/test_checkpoint_status.py @@ -44,6 +44,9 @@ from render_checkpoint_status import ( # noqa: E402 CAMPAIGN, CENSUS_MD, + CLI_CAMPAIGNS, + CLI_CENSUS_MD, + CLI_MUTATIONS_MD, COORD_CAMPAIGNS, COORD_CENSUS_MD, COORD_MUTATIONS_MD, @@ -67,7 +70,7 @@ DEFINITIONS = (CAMPAIGN, *(os.path.join(EVIDENCE, f"{campaign}.json") for _, campaign in (*CP4B_CAMPAIGNS, *CP5_CAMPAIGNS, *SHADOW_CAMPAIGNS, - *COORD_CAMPAIGNS))) + *COORD_CAMPAIGNS, *CLI_CAMPAIGNS))) def _anchors() -> list[str]: @@ -99,7 +102,8 @@ def run() -> int: print(f"checkpoint status fragments OK: {CENSUS_MD}, {CP1_CENSUS_MD}, " f"{COORD_CENSUS_MD}, {INVENTORY_MD}, " f"{MUTATIONS_MD}, {CP4B_MUTATIONS_MD}, {CP5_MUTATIONS_MD}, {COORD_MUTATIONS_MD}, " - f"{SHADOW_CENSUS_MD}, {SHADOW_MUTATIONS_MD}, {SHADOW_SWEEP_MD} in sync " + f"{SHADOW_CENSUS_MD}, {SHADOW_MUTATIONS_MD}, {SHADOW_SWEEP_MD}, " + f"{CLI_CENSUS_MD}, {CLI_MUTATIONS_MD} in sync " f"with the evidence; " f"{len(DEFINITIONS)} campaign definitions still anchor") return 0 diff --git a/tests/test_cli_ownir_fixtures.py b/tests/test_cli_ownir_fixtures.py new file mode 100644 index 00000000..bfb5bccd --- /dev/null +++ b/tests/test_cli_ownir_fixtures.py @@ -0,0 +1,768 @@ +#!/usr/bin/env python3 +"""The frozen `own-cli ownir` CLI contract (P-022 step 7b, #261 261.B). + +Python is the oracle for everything **after `ownir` is selected**; the +top-level shell is a cross-implementation parity surface with no Python byte +oracle at all. C-1 of #261's ratified decision packet draws that line at the +*surface*, never at the reference's internal print branch, so a case is tagged +with the oracle that authored it and a reader can tell the classes apart: + + python an executed `python -m ownlang ownir ...` run + python-docstring the same, and the bytes are the whole module docstring on + stdout (a positional-count error or an unknown argument). + Frozen as measured AND flagged, so the owner can declare + that exact class a defect without having to find it. + owen-convention no Python oracle exists: authored once from the help text + carried in the manifest, following the public `owen` + convention (frontend/roslyn/OwnSharp.Cli/Program.cs). + +What this module is *for* is the Rust replay +(`rust/crates/own-cli/tests/replay.rs`), which runs the built binary against +these bytes with **zero Python**. This side proves the bytes are still the +reference's. + +Run: python tests/test_cli_ownir_fixtures.py (verify) + python tests/test_cli_ownir_fixtures.py --write (regenerate) + python tests/run_tests.py (in the suite) + +Two things this writer refuses to do, both on purpose: + +* it never writes a case whose two runs disagree — a fixture that is not + byte-deterministic is not a contract, it is a coin flip with a filename; +* it never writes a raw `.txt` expectation. Streams live inside JSON strings + so a checkout with `core.autocrlf` on cannot corrupt an expectation (#343). +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_HERE) +sys.path.insert(0, _ROOT) + +FIXTURE_DIR = os.path.join(_HERE, "fixtures", "cli_ownir") +MANIFEST = os.path.join(FIXTURE_DIR, "manifest.json") +CLI_OWNIR_VERSION = 1 + +# The one placeholder the format admits. It consumes the rest of the line it +# appears on, and the replay requires it to consume something non-empty. It may +# appear ONLY on a line carrying an OS error text: `cannot read : ` +# is platform-native (`[Errno 2] No such file or directory: '...'` on Linux, +# `[Errno 13] Permission denied` where Windows refuses a directory), so the +# contract is the line up to and including `cannot read : ` and the tail +# is recorded per platform rather than asserted. The same choice +# tests/test_cli_contract.py already made with its "cannot read" substring. +OS_ERROR = "" +_READ_MARK = "cannot read " + +# The one declared cross-implementation boundary, named so it is machine- +# recognizable and cannot quietly widen into "strict-door wording may differ". +# +# CLI-B1 JSON_PARSER_DETAIL (applies iff OwnIrErrorKind == Json) +# pinned: exit 2 · stderr · kind == Json · the FULL CLI-owned wrapper, +# byte-exact: "{path}: error: {path} is not valid JSON: " +# declared: only the bytes AFTER that prefix — the parser library's text +# (CPython "Expecting value: line 1 column 1 (char 0)" vs +# serde_json "EOF while parsing a value at line 1 column 0") +# +# Every other rejection family is byte-exact. Version in particular was FIXED +# to byte parity (#261 ruling 2a) rather than declared, and its cases below +# double as the negative control proving CLI-B1 cannot reach a non-Json kind. +CLI_B1 = {"id": "CLI-B1", "expected_kind": "json"} + + +class NonDeterministic(RuntimeError): + """Two runs of the reference disagreed. A case like that is not a contract, + so `--write` refuses to write it and `run()` reports it as a failure — + never as a `SystemExit`, which would end the aggregate runner at import + time (`tests/_preflight.py` forbids exactly that).""" + +# -------------------------------------------------------------------------- +# The `owen-convention` surface: authored ONCE, here, and carried in the +# manifest. The binary holds the same two strings as consts and +# rust/crates/own-cli/tests/replay.rs asserts they are equal, so the text +# cannot drift between the two halves of the contract. +# -------------------------------------------------------------------------- + +OWN_CLI_VERSION = "0.1.0" # asserted against CARGO_PKG_VERSION by the replay + +SHELL_USAGE = """\ +own-cli — the Own.NET core as a native executable. + +Usage: + own-cli ownir [options] check OwnIR facts extracted from C# + +Options (ownir): + --format {human|github|msbuild|sarif} finding surface (default: human) + --severity {error|warning} how a finding is shown (default: error) + --verbosity {quiet|normal|verbose} quiet hides the advisory notes; + verbose adds a per-code breakdown + (default: normal) + --help, -h print this help + --version print the version + +Both `--flag value` and `--flag=value` are accepted. `ownir` takes exactly one +positional argument, the facts file; there is no `--` separator and there are +no short flags. + +Exit codes: + 0 clean + 1 findings — any non-advisory, unsuppressed finding, independent of + --severity + 2 usage error, or a facts document the strict door refuses + 70 internal error — a bug in the analyzer, never silence +""" + +OWNIR_USAGE = """\ +own-cli ownir — check OwnIR facts extracted from C# by the Roslyn frontend. + +Usage: + own-cli ownir [--format F] [--severity S] [--verbosity V] + +Options: + --format {human|github|msbuild|sarif} human is the default CLI line, github + a CI annotation, msbuild the VS Error + List line, sarif a SARIF 2.1.0 log + --severity {error|warning} how the host shows a finding; it never + changes the exit code + --verbosity {quiet|normal|verbose} quiet hides the advisory notes + (OWN050/051/052, OBL005); verbose adds + a per-code breakdown over every + finding, suppressed ones included + +Both `--flag value` and `--flag=value` are accepted. Exactly one positional +argument; there is no `--` separator and there are no short flags. + +Exit codes: + 0 no leaks + 1 at least one non-advisory, unsuppressed finding + 2 usage error, or a facts document the strict door refuses + 70 internal error — a bug in the analyzer, never silence +""" + +_UNKNOWN_COMMAND = "own-cli: unknown command {name!r}\n" + + +def _shell(stdout: str = "", stderr: str = "", exit_code: int = 0) -> dict: + return {"exit": exit_code, "stdout": stdout, "stderr": stderr, + "os_error_tail": None} + + +# -------------------------------------------------------------------------- +# The case list. Declared, never swept: every case names what it is a control +# for, and the census fragment is rendered from these rules. +# -------------------------------------------------------------------------- + +# Paths are relative to the fixture directory, which is also the default cwd, +# so the bytes a case freezes carry a relative path that means the same thing +# on both platforms and in a fresh clone. +_CLEAN = "../verdict_renders/render_empty.facts.json" +_LEAKY = "../verdict_renders/render_columns.facts.json" +_ADVISORY = "../verdict_renders/render_anchorless.facts.json" +_BANDS = "../verdict_renders/render_tiers_and_levels.facts.json" +_ESCAPING = "../verdict_renders/render_escaping.facts.json" +_REFUSAL = "../verdict_renders/render_refusal.facts.json" +_SUPPRESSED = "inputs/suppressed_only.facts.json" +_NONASCII = "inputs/nonascii_file.facts.json" +_EMPTY_REASON = "inputs/empty_ignore_reason.facts.json" +_SPACED = "inputs/pa th ünïcødé/facts.json" +_NOT_OBJECT = "inputs/not_an_object.facts.json" +# Deliberately NOT JSON, and named so. The `.broken` suffix is not cosmetic: +# `tests/coordinate_census.py` sweeps every `.json` under `tests/fixtures/` and +# would (rightly) call an unparseable one a broken fixture. A file whose whole +# job is to be invalid JSON should not claim the extension — the CLI reads the +# path it is given and never looks at the suffix. +_JSON_EMPTY = "inputs/not_json_empty.facts.broken" +_JSON_TRUNCATED = "inputs/not_json_truncated.facts.broken" +_JSON_BOM = "inputs/not_json_bom.facts.broken" +_VER_MISMATCH = "inputs/version_mismatch.facts.json" +_VER_STRING = "inputs/version_wrong_type_string.facts.json" +_VER_BOOL = "inputs/version_wrong_type_bool.facts.json" +_VER_FLOAT = "inputs/version_wrong_type_float.facts.json" +_VER_NULL = "inputs/version_wrong_type_null.facts.json" + + +class Case: + """One frozen invocation. + + `oracle` decides who authors the bytes; `rules` is what the case is the + control for (the census groups by them); `expected` is filled in by + `--write` for a python oracle and stated here for an owen-convention one. + """ + + def __init__(self, name: str, argv: list[str], *, oracle: str, + rules: list[str], pins: list[str], cwd: str = ".", + env: dict[str, str] | None = None, + expected: dict | None = None, + boundary: dict | None = None) -> None: + self.name = name + self.argv = argv + self.oracle = oracle + self.rules = rules + self.pins = pins + self.cwd = cwd + self.env = env or {} + self.expected = expected + # Structured boundary metadata, NOT a second placeholder. A case that + # carries one names the declared boundary by id and the rejection kind + # the replay must PROVE before it relaxes anything (#261 ruling 2b). + # One strictly-bounded placeholder (``) is the whole budget; + # a second would turn this format into a little language of excuses. + self.boundary = boundary + + +def _display_cases() -> list[Case]: + """The display policy: which findings are shown, the summary and `ok` + lines, the stream split, and the four formats through the process + boundary.""" + out: list[Case] = [] + + # A clean document in every format: the machine formats must write ZERO + # bytes to stdout, and `human` must write the ok line and the summary there. + for fmt in ("human", "github", "msbuild", "sarif"): + out.append(Case( + f"clean-{fmt}", ["ownir", _CLEAN, "--format", fmt], + oracle="python", + rules=(["ok-line", "stream-split"] + + (["machine-stdout-empty-when-clean"] + if fmt in ("github", "msbuild") else [])), + pins=[f"a clean document rendered as {fmt}"])) + + # The failing tier in every format, at both host severities: --severity + # changes the rendered level and NEVER the exit code. + for fmt in ("human", "github", "msbuild", "sarif"): + for sev in ("error", "warning"): + out.append(Case( + f"leaky-{fmt}-{sev}", ["ownir", _LEAKY, "--format", fmt, + "--severity", sev], + oracle="python", + rules=["exit-independent-of-severity", "stream-split"], + pins=[f"two OWN001 leaks as {fmt} at --severity {sev}"])) + + # All four bands in one document (leak + intrinsic-warning leak + advisory + # + suppressed) across the whole verbosity axis and both severities. This is + # where quiet/normal/verbose, the summary tails and the SARIF list are + # proved together. + for fmt in ("human", "github", "msbuild", "sarif"): + for sev in ("error", "warning"): + for verb in ("quiet", "normal", "verbose"): + rules = ["exit-independent-of-severity", "stream-split", + "summary-suppressed-tail"] + if verb == "quiet": + rules.append("quiet-hides-advisory") + if verb == "verbose": + rules.append("verbose-counts-every-finding") + if fmt == "sarif": + rules.append("sarif-carries-shown-plus-suppressed") + out.append(Case( + f"bands-{fmt}-{sev}-{verb}", + ["ownir", _BANDS, "--format", fmt, "--severity", sev, + "--verbosity", verb], + oracle="python", rules=rules, + pins=[f"all four bands as {fmt}, --severity {sev}, " + f"--verbosity {verb}"])) + + # Advisory-only: exit 0 even though something is printed, and `quiet` hides + # it without touching the exit. + for verb in ("quiet", "normal", "verbose"): + out.append(Case( + f"advisory-only-{verb}", ["ownir", _ADVISORY, "--verbosity", verb], + oracle="python", + rules=["advisory-never-fails-the-run", "quiet-hides-advisory", + "verbose-counts-every-finding"], + pins=["an OWN052 advisory and no leak: exit 0"])) + + # Suppressed-only: the `ok` line AND a suppressed tally in one run, and a + # verbose breakdown that counts a finding nothing showed. + for verb in ("normal", "verbose"): + out.append(Case( + f"suppressed-only-{verb}", ["ownir", _SUPPRESSED, + "--verbosity", verb], + oracle="python", + rules=["ok-line-with-suppressed", "summary-suppressed-tail", + "verbose-counts-every-finding"], + pins=["every finding [OwnIgnore]-suppressed: ok, a tally, exit 0"])) + out.append(Case( + "suppressed-only-sarif", ["ownir", _SUPPRESSED, "--format", "sarif"], + oracle="python", + rules=["sarif-carries-shown-plus-suppressed", "ok-line-with-suppressed"], + pins=["a suppressed finding still rides in the SARIF results"])) + + # BR-V6: an empty-string reason never suppresses. + out.append(Case( + "empty-ignore-reason-does-not-suppress", ["ownir", _EMPTY_REASON], + oracle="python", rules=["empty-reason-never-suppresses"], + pins=["ignore_reason='' leaves the finding shown and the exit at 1"])) + + # Paths and encodings, through the formats that render them differently. + out.append(Case( + "path-nonascii-file-human", ["ownir", _NONASCII], + oracle="python", rules=["nonascii-in-file-field"], + pins=["a non-ASCII (and astral) `file` on the human line"])) + out.append(Case( + "path-nonascii-file-github", ["ownir", _NONASCII, "--format", "github"], + oracle="python", rules=["nonascii-in-file-field"], + pins=["the same, as a GitHub annotation"])) + out.append(Case( + "path-nonascii-file-sarif", ["ownir", _NONASCII, "--format", "sarif"], + oracle="python", rules=["sarif-ascii-escape", "nonascii-in-file-field"], + pins=["json.dumps' ASCII escaping, surrogate pair above the BMP"])) + out.append(Case( + "sarif-ascii-escape-em-dash", ["ownir", _LEAKY, "--format", "sarif"], + oracle="python", rules=["sarif-ascii-escape"], + pins=["the message's U+2014 leaves as the six ASCII characters " + "\\u2014, NOT the literal bytes the BR-V9 goldens carry"])) + out.append(Case( + "path-backslash-file-github", ["ownir", _ESCAPING, "--format", "github"], + oracle="python", rules=["windows-path-form"], + pins=["a backslash `file` and the %3A/%2C property escaping"])) + out.append(Case( + "path-backslash-file-sarif", ["ownir", _ESCAPING, "--format", "sarif"], + oracle="python", rules=["windows-path-form"], + pins=["SARIF folds the backslash to a forward slash in the uri"])) + out.append(Case( + "path-with-space-and-nonascii", ["ownir", _SPACED], + oracle="python", rules=["nonascii-in-path"], + pins=["the facts PATH carries a space and non-ASCII"])) + + # OWNLANG_DEBUG is scrubbed from the inherited environment unless a case + # sets it; this case sets it and proves it changes nothing for a run that + # does not crash. + out.append(Case( + "debug-env-does-not-change-ordinary-output", ["ownir", _LEAKY], + oracle="python", rules=["debug-env-is-inert-when-nothing-crashes"], + env={"OWNLANG_DEBUG": "1"}, + pins=["OWNLANG_DEBUG only ever changes the internal-error path"])) + + # cwd is part of the case: the same document reached from a subdirectory + # echoes the path it was given, not a resolved one. + out.append(Case( + "cwd-relative-path-is-echoed-as-given", ["ownir", "facts.json"], + cwd="inputs/pa th ünïcødé", + oracle="python", rules=["path-echoed-as-given", "nonascii-in-path"], + pins=["the summary echoes argv, never a resolved path"])) + return out + + +def _usage_cases() -> list[Case]: + """Everything after `ownir` that answers with a usage error.""" + doc = ["docstring-on-stdout"] + return [ + # The docstring-on-stdout class, frozen AND flagged (C-1). + Case("usage-no-positional", ["ownir"], oracle="python-docstring", + rules=doc, pins=["zero positionals prints the whole module " + "docstring to stdout, exit 2"]), + Case("usage-two-positionals", ["ownir", _CLEAN, _CLEAN], + oracle="python-docstring", rules=doc, + pins=["two positionals: the same docstring"]), + Case("usage-unknown-flag-with-path", ["ownir", "--bogus", _CLEAN], + oracle="python-docstring", rules=[*doc, "unknown-flag-is-positional"], + pins=["an unknown flag is a POSITIONAL to the reference's parser, " + "so with a real path it is two positionals"]), + Case("usage-double-dash-not-a-separator", ["ownir", "--", _CLEAN], + oracle="python-docstring", rules=[*doc, "no-double-dash-separator"], + pins=["`--` is an ordinary positional; there is no separator"]), + + # The other half of the unknown-flag behaviour: alone it is ONE + # positional, so it reaches the ordinary path and fails to open. + Case("usage-unknown-flag-alone", ["ownir", "--bogus"], oracle="python", + rules=["unknown-flag-is-positional", "os-error-placeholder"], + pins=["one positional named --bogus: the read fails, exit 2"]), + + # A flag with no value. + *[Case(f"usage-{flag[2:]}-missing-value", ["ownir", _CLEAN, flag], + oracle="python", rules=["missing-flag-value"], + pins=[f"`{flag}` with nothing after it"]) + for flag in ("--format", "--severity", "--verbosity")], + + # An invalid value for each flag. --format names `json` among the + # choices because it passes the GLOBAL value gate. + Case("usage-format-invalid", ["ownir", _CLEAN, "--format", "x"], + oracle="python", rules=["invalid-flag-value"], + pins=["the global _FORMATS gate, which still lists json"]), + Case("usage-format-empty-value", ["ownir", _CLEAN, "--format="], + oracle="python", rules=["invalid-flag-value", "equals-spelling"], + pins=["`--format=` is an empty value, not a missing one"]), + Case("usage-format-value-is-a-flag", + ["ownir", _CLEAN, "--format", "--severity"], + oracle="python", rules=["invalid-flag-value"], + pins=["a following flag is consumed as the value"]), + Case("usage-severity-invalid", ["ownir", _CLEAN, "--severity", "x"], + oracle="python", rules=["invalid-flag-value"], pins=["error|warning"]), + Case("usage-verbosity-invalid", ["ownir", _CLEAN, "--verbosity", "x"], + oracle="python", rules=["invalid-flag-value"], + pins=["quiet|normal|verbose"]), + + # `json` passes the global gate and is refused on the ownir branch with + # its own message, naming the four surfaces in ITS order. + Case("usage-format-json-rejected-by-ownir", + ["ownir", _CLEAN, "--format", "json"], oracle="python", + rules=["invalid-flag-value", "ownir-format-scope"], + pins=["the cfg seam's format is not an ownir surface"]), + + # The `=` spelling is accepted, and a duplicate is not an error. + Case("usage-equals-spelling-accepted", + ["ownir", _CLEAN, "--format=sarif"], oracle="python", + rules=["equals-spelling"], + pins=["--flag=value is identical to --flag value"]), + Case("usage-duplicate-flag-last-wins", + ["ownir", _CLEAN, "--format=human", "--format=sarif"], + oracle="python", rules=["equals-spelling", "duplicate-flag"], + pins=["a repeated flag is not an error; the last one wins"]), + ] + + +def _refusal_cases() -> list[Case]: + """The strict door, and the OS-error class.""" + return [ + Case("refuse-missing-file", ["ownir", "inputs/no_such_facts.json"], + oracle="python", rules=["os-error-placeholder", "strict-door"], + pins=["a missing facts file is a polite exit 2, never a crash"]), + Case("refuse-directory", ["ownir", "inputs"], oracle="python", + rules=["os-error-placeholder", "strict-door"], + pins=["a directory is an OS error, and its text is platform-native"]), + Case("refuse-root-not-an-object", ["ownir", _NOT_OBJECT], + oracle="python", rules=["strict-door"], + pins=["the shape refusal, byte-identical in both implementations"]), + Case("refuse-unknown-flow-op", ["ownir", _REFUSAL], oracle="python", + rules=["strict-door"], + pins=["the vocabulary refusal, already byte-pinned by BR-V9"]), + # #261 ruling 2a — the Version family is FIXED to byte parity, not + # declared. The text is ours on both sides, so a divergence was a Rust + # bug: `serde_json::Value`'s Display spelled a string with double + # quotes, a bool lowercase and null as `null`, where the reference + # interpolates a Python `repr`. One case per ledger value variant. + Case("refuse-version-wrong-type-string", ["ownir", _VER_STRING], + oracle="python", rules=["strict-door", "version-byte-parity"], + pins=["a string version: repr single-quotes it, `got '0'`"]), + Case("refuse-version-wrong-type-bool", ["ownir", _VER_BOOL], + oracle="python", rules=["strict-door", "version-byte-parity"], + pins=["a bool version: repr capitalizes it, `got True`"]), + Case("refuse-version-wrong-type-float", ["ownir", _VER_FLOAT], + oracle="python", rules=["strict-door", "version-byte-parity"], + pins=["a float version: the one wrong-type value that already " + "agreed, kept so the agreement is asserted rather than " + "assumed"]), + Case("refuse-version-wrong-type-null", ["ownir", _VER_NULL], + oracle="python", rules=["strict-door", "version-byte-parity"], + pins=["a null version: repr writes `got None`, not `null` — the " + "divergence the ruling's own list did not enumerate"]), + # Also CLI-B1's NEGATIVE CONTROL: same argv shape, same path shape, + # same valid UTF-8, same fixture machinery — only the rejection KIND + # moved, Json -> Version. The replay asserts the relaxed matcher + # refuses it, so the guard is proven to be on the kind and nothing else. + Case("refuse-version-mismatch", ["ownir", _VER_MISMATCH], + oracle="python", + rules=["strict-door", "version-byte-parity", "cli-b1-negative-control"], + pins=["the schema-mismatch wording, byte-exact including " + "'Roslyn extractor' and 'Python core'; and the negative " + "control that CLI-B1 cannot match a non-Json rejection"]), + + # #261 ruling 2b — CLI-B1. The CLI-owned wrapper is pinned byte-exact + # and ONLY the parser library's own detail after it is declared. + Case("refuse-json-empty-file", ["ownir", _JSON_EMPTY], oracle="python", + rules=["strict-door", "cli-b1-json-parser-detail"], + boundary=CLI_B1, + pins=["an empty file: the wrapper is pinned, the parser detail " + "(CPython 'Expecting value...' vs serde 'EOF while...') is " + "the declared tail"]), + Case("refuse-json-truncated", ["ownir", _JSON_TRUNCATED], + oracle="python", rules=["strict-door", "cli-b1-json-parser-detail"], + boundary=CLI_B1, + pins=["a truncated document: the same wrapper, a different tail"]), + Case("refuse-json-bom", ["ownir", _JSON_BOM], oracle="python", + rules=["strict-door", "cli-b1-json-parser-detail"], + boundary=CLI_B1, + pins=["a UTF-8 BOM: valid UTF-8, invalid JSON — it must reach " + "CLI-B1 and not R4's invalid-UTF-8 defect"]), + + # The stdin ruling, recorded EXPLICITLY rather than silently: `-` is not + # a stdin marker to the reference, it is a file name. + Case("stdin-dash-is-out-of-contract", ["ownir", "-"], oracle="python", + rules=["stdin-out-of-contract", "os-error-placeholder"], + pins=["the ruling: stdin is not part of the contract. The " + "reference opens the literal path '-' and fails; recorded " + "here so 'out of contract' is a fixture, not a silence"]), + ] + + +def _shell_cases() -> list[Case]: + """The top-level shell — a parity surface of its own (C-1). No Python + oracle exists for any of these, and `ownir --help` is the one case after + `ownir` the owner declared a defect and did NOT port.""" + conv = "owen-convention" + return [ + Case("shell-empty-invocation", [], oracle=conv, + rules=["usage-owen-shape"], + pins=["the empty invocation prints help to stdout and exits 2"], + expected=_shell(stdout=SHELL_USAGE, exit_code=2)), + Case("shell-help-long", ["--help"], oracle=conv, + rules=["usage-owen-shape"], pins=["--help is a success"], + expected=_shell(stdout=SHELL_USAGE, exit_code=0)), + Case("shell-help-short", ["-h"], oracle=conv, + rules=["usage-owen-shape"], pins=["-h is the same as --help"], + expected=_shell(stdout=SHELL_USAGE, exit_code=0)), + Case("shell-version", ["--version"], oracle=conv, + rules=["usage-owen-shape"], pins=["own-cli on stdout"], + expected=_shell(stdout=f"own-cli {OWN_CLI_VERSION}\n", exit_code=0)), + Case("shell-unknown-command", ["bogus"], oracle=conv, + rules=["usage-owen-shape"], + pins=["distinct from the empty invocation: one error line and the " + "help, both on STDERR, exit 2"], + expected=_shell( + stderr=_UNKNOWN_COMMAND.format(name="bogus") + SHELL_USAGE, + exit_code=2)), + Case("shell-unknown-command-that-looks-like-a-flag", ["--nope"], + oracle=conv, rules=["usage-owen-shape"], + pins=["an unrecognised flag at the SHELL is an unknown command; " + "the reference's positional-swallowing parser is a branch " + "after `ownir`, not the surface"], + expected=_shell( + stderr=_UNKNOWN_COMMAND.format(name="--nope") + SHELL_USAGE, + exit_code=2)), + Case("ownir-help-is-the-declared-defect", ["ownir", "--help"], + oracle=conv, rules=["usage-owen-shape", "declared-defect"], + pins=["C-1: the reference answers `cannot read --help` with exit " + "2. That is declared a defect and NOT ported: the shell " + "convention extends to the subcommand"], + expected=_shell(stdout=OWNIR_USAGE, exit_code=0)), + ] + + +def cases() -> list[Case]: + return (_shell_cases() + _usage_cases() + _refusal_cases() + + _display_cases()) + + +# -------------------------------------------------------------------------- +# Running the reference +# -------------------------------------------------------------------------- + +def _reference(case: Case) -> tuple[int, str, str]: + """One `python -m ownlang ownir ...` run, from the case's cwd, with + OWNLANG_DEBUG scrubbed from the inherited environment unless the case sets + it. `-m ownlang` needs the repo on the path, and the cwd is the fixture + directory, so PYTHONPATH carries the root explicitly.""" + env = {k: v for k, v in os.environ.items() if k != "OWNLANG_DEBUG"} + env["PYTHONPATH"] = _ROOT + os.pathsep + env.get("PYTHONPATH", "") + env.update(case.env) + proc = subprocess.run( + [sys.executable, "-m", "ownlang", *case.argv], + capture_output=True, check=False, env=env, + cwd=os.path.join(FIXTURE_DIR, case.cwd)) + return (proc.returncode, + proc.stdout.decode("utf-8", "surrogateescape"), + proc.stderr.decode("utf-8", "surrogateescape")) + + +def _placeholder(stderr: str) -> tuple[str, str | None]: + """Replace the platform-native OS error tail with `` and hand back + the tail that was replaced. Only the text after `cannot read : ` on + that one line is replaced — the rest of the line is contract.""" + idx = stderr.find(_READ_MARK) + if idx < 0: + return stderr, None + # The tail starts after the ": " that follows the echoed path. + colon = stderr.find(": ", idx + len(_READ_MARK)) + if colon < 0: + return stderr, None + start = colon + 2 + end = stderr.find("\n", start) + if end < 0: + end = len(stderr) + tail = stderr[start:end] + if not tail: + return stderr, None + return stderr[:start] + OS_ERROR + stderr[end:], tail + + +def _expectation(case: Case) -> dict: + """The expectation for one python-oracle case, taken twice: a case whose + two runs disagree is not written at all.""" + first = _reference(case) + second = _reference(case) + if first != second: + raise NonDeterministic( + f"case {case.name!r} is NOT deterministic — two runs of the " + f"reference disagreed, so it cannot be a contract.\n" + f" first : {first!r}\n second: {second!r}") + code, out, err = first + err, tail = _placeholder(err) + return {"exit": code, "stdout": out, "stderr": err, + "os_error_tail": {"linux": tail} if tail else None} + + +def _case_path(name: str) -> str: + return os.path.join(FIXTURE_DIR, f"{name}.case.json") + + +def _dump(path: str, payload: dict) -> None: + """Write UTF-8 with `\\n` endings, deterministically. Streams are JSON + strings, so a `core.autocrlf` checkout cannot corrupt an expectation.""" + with open(path, "w", encoding="utf-8", newline="\n") as handle: + json.dump(payload, handle, indent=2, ensure_ascii=False, + sort_keys=False) + handle.write("\n") + + +def write() -> int: + os.makedirs(FIXTURE_DIR, exist_ok=True) + entries = [] + for case in cases(): + try: + expected = (case.expected if case.oracle == "owen-convention" + else _expectation(case)) + except NonDeterministic as exc: + print(f"cli_ownir: refusing to write — {exc}") + return 1 + payload = { + "cli_ownir_version": CLI_OWNIR_VERSION, + "argv": case.argv, + "cwd": case.cwd, + "env": case.env, + "expected": expected, + } + if case.boundary is not None: + payload["boundary"] = case.boundary + _dump(_case_path(case.name), payload) + entry = {"name": case.name, "oracle": case.oracle, + "rules": case.rules, "pins": case.pins} + if case.boundary is not None: + entry["boundary"] = case.boundary + entries.append(entry) + _dump(MANIFEST, { + "comment": ( + "The frozen `own-cli ownir` CLI contract (#261 261.B). Authoritative " + "via `python tests/test_cli_ownir_fixtures.py --write` on LINUX; " + "replayed against the built binary with zero Python by " + "rust/crates/own-cli/tests/replay.rs on Linux and Windows. `oracle` " + "says who authored a case's bytes: `python` an executed reference " + "run, `python-docstring` the same where the bytes are the whole " + "module docstring on stdout (frozen as measured AND flagged so the " + "owner can declare that class a defect), `owen-convention` the " + "top-level shell, which has no Python byte oracle at all."), + "cli_ownir_version": CLI_OWNIR_VERSION, + "own_cli_version": OWN_CLI_VERSION, + "shell_usage": SHELL_USAGE, + "ownir_usage": OWNIR_USAGE, + "unknown_command_line": _UNKNOWN_COMMAND, + "os_error_placeholder": OS_ERROR, + "declared_boundaries": { + "CLI-B1": { + "name": "JSON_PARSER_DETAIL", + "applies_iff": "OwnIrErrorKind == Json", + "pinned": "exit 2, stderr, kind == Json, and the full CLI-owned " + "wrapper byte-exact: '{path}: error: {path} is not " + "valid JSON: '", + "declared": "only the bytes AFTER that prefix — the parser " + "library's own text", + "guard": "rust/crates/own-cli/tests/replay.rs proves valid " + "UTF-8, then OwnIr::from_json rejecting, then " + "kind == Json, BEFORE relaxing anything", + }, + }, + "cases": entries, + }) + print(f"cli_ownir fixtures written: {len(entries)} cases -> {FIXTURE_DIR}") + return 0 + + +def run() -> int: + fails: list[str] = [] + checks = 0 + hint = "'python tests/test_cli_ownir_fixtures.py --write'" + + if not os.path.exists(MANIFEST): + print(f"FAIL: cli_ownir manifest missing — run {hint}") + return 1 + with open(MANIFEST, encoding="utf-8") as handle: + manifest = json.load(handle) + + checks += 1 + if manifest.get("cli_ownir_version") != CLI_OWNIR_VERSION: + fails.append(f"manifest cli_ownir_version is " + f"{manifest.get('cli_ownir_version')!r}, expected " + f"{CLI_OWNIR_VERSION}") + + # The owen-convention surface has one source of truth, and it is here. + for key, want in (("shell_usage", SHELL_USAGE), + ("ownir_usage", OWNIR_USAGE), + ("own_cli_version", OWN_CLI_VERSION)): + checks += 1 + if manifest.get(key) != want: + fails.append(f"manifest {key} has drifted from this module — " + f"the binary shares it, so run {hint}") + + declared = cases() + by_name = {c.name: c for c in declared} + listed = [e["name"] for e in manifest.get("cases", [])] + + checks += 1 + if listed != [c.name for c in declared]: + fails.append("manifest case list differs from the declared cases " + f"(stale or reordered) — run {hint}") + + # No orphan case files: a file nobody lists is a fixture nobody replays. + on_disk = {f[:-len(".case.json")] for f in os.listdir(FIXTURE_DIR) + if f.endswith(".case.json")} + checks += 1 + for orphan in sorted(on_disk - set(by_name)): + fails.append(f"orphan case file {orphan}.case.json — run {hint}") + + for case in declared: + path = _case_path(case.name) + checks += 1 + if not os.path.exists(path): + fails.append(f"missing case file for {case.name!r} — run {hint}") + continue + with open(path, encoding="utf-8") as handle: + stored = json.load(handle) + if stored.get("cli_ownir_version") != CLI_OWNIR_VERSION: + fails.append(f"{case.name}: cli_ownir_version drift") + continue + if stored.get("argv") != case.argv or stored.get("cwd") != case.cwd: + fails.append(f"{case.name}: argv/cwd differ from the declaration " + f"— run {hint}") + continue + if stored.get("boundary") != case.boundary: + fails.append(f"{case.name}: the declared boundary differs from the " + f"declaration — run {hint}") + continue + if case.oracle == "owen-convention": + # No Python to consult: the stored bytes must still be the ones + # this module authors. + if stored.get("expected") != case.expected: + fails.append(f"{case.name}: the owen-convention expectation " + f"has drifted — run {hint}") + continue + try: + fresh = _expectation(case) + except NonDeterministic as exc: + fails.append(str(exc)) + continue + if stored.get("expected") != fresh: + fails.append( + f"{case.name}: the frozen bytes are no longer what the " + f"reference produces.\n stored: " + f"{json.dumps(stored.get('expected'), ensure_ascii=False)}\n" + f" fresh : {json.dumps(fresh, ensure_ascii=False)}\n" + f" If Python moved deliberately, run {hint}; if it did not, " + f"this is a regression.") + + if fails: + for failure in fails: + print(f"FAIL: cli_ownir {failure}") + return 1 + print(f"cli_ownir CLI contract OK: {checks} checks over {len(declared)} " + f"cases (Python oracle; the Rust replay runs the binary)") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + raise SystemExit(write()) + raise SystemExit(run())