diff --git a/docs/evidence/p022-cp4-mutations.json b/docs/evidence/p022-cp4-mutations.json index 060ff7f7..6477aa34 100644 --- a/docs/evidence/p022-cp4-mutations.json +++ b/docs/evidence/p022-cp4-mutations.json @@ -68,12 +68,12 @@ { "id": "M06", "rule": "BR-V7", - "description": "BR-V7 dedup key drops event", + "description": "BR-V7 dedup key drops event (unobservable end to end since cp5.1 put `message` in the key)", "target": "rust/crates/own-bridge/src/verdict.rs", "pattern": "(\\n\\s+)f\\.event\\.clone\\(\\),(\\n\\s+f\\.handler\\.clone\\(\\),)", "replacement": "\\1String::new(),\\2", "expected_catchers": [ - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member" ] }, { @@ -125,8 +125,8 @@ "rule": "BR-M1", "description": "BR-M1 OWN052 note never minted", "target": "rust/crates/own-bridge/src/verdict.rs", - "pattern": "for _reason in &lowering\\.mos_notes \\{", - "replacement": "for _reason in lowering.mos_notes.iter().skip(usize::MAX) {", + "pattern": "for reason in &lowering\\.mos_notes \\{", + "replacement": "for reason in lowering.mos_notes.iter().skip(usize::MAX) {", "expected_catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ] @@ -193,8 +193,8 @@ "rule": "BR-V6", "description": "BR-V6 an empty ignore_reason suppresses", "target": "rust/crates/own-bridge/src/verdict.rs", - "pattern": "\\.filter\\(\\|v\\| truthy\\(Some\\(v\\)\\)\\)", - "replacement": ".filter(|v| !v.is_null())", + "pattern": "\\.get\\(\"ignore_reason\"\\)\\n(\\s+)\\.filter\\(\\|v\\| truthy\\(Some\\(v\\)\\)\\)", + "replacement": ".get(\"ignore_reason\")\n\\1.filter(|v| !v.is_null())", "expected_catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ] @@ -278,7 +278,7 @@ "rule": "BR-V7", "description": "BR-V7 dedup key drops handler", "target": "rust/crates/own-bridge/src/verdict.rs", - "pattern": "(\\n\\s+)f\\.handler\\.clone\\(\\),(\\n\\s+f\\.kind\\.clone\\(\\),)", + "pattern": "(\\n\\s+)f\\.handler\\.clone\\(\\),(\\n\\s+f\\.message\\.clone\\(\\),)", "replacement": "\\1String::new(),\\2", "expected_catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" @@ -298,23 +298,23 @@ { "id": "M27", "rule": "BR-V7", - "description": "BR-V7 dedup key drops kind", + "description": "BR-V7 dedup key drops kind (unobservable end to end since cp5.1 put `message` in the key)", "target": "rust/crates/own-bridge/src/verdict.rs", "pattern": "(\\n\\s+)f\\.kind\\.clone\\(\\),(\\n\\s+f\\.advisory,)", "replacement": "\\1String::new(),\\2", "expected_catchers": [ - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member" ] }, { "id": "M28", "rule": "BR-V7", - "description": "BR-V7 dedup key drops severity", + "description": "BR-V7 dedup key drops severity (unobservable end to end since cp5.1 put `message` in the key)", "target": "rust/crates/own-bridge/src/verdict.rs", "pattern": "(\\n\\s+)f\\.severity\\.clone\\(\\),(\\n\\s+f\\.ignore_reason\\.clone\\(\\),)", "replacement": "\\1None,\\2", "expected_catchers": [ - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member" ] }, { diff --git a/docs/evidence/p022-cp4-mutations.result.json b/docs/evidence/p022-cp4-mutations.result.json index 28c25538..53846071 100644 --- a/docs/evidence/p022-cp4-mutations.result.json +++ b/docs/evidence/p022-cp4-mutations.result.json @@ -3,10 +3,10 @@ "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-cp4", "definition": "docs/evidence/p022-cp4-mutations.json", - "definition_sha256": "6f858c6bf6a24cc05cdbcfe2c9a43741576a31d1c5d580d6977222a173340424", - "source_commit": "a529193e3acaeab435c435aff8606707cee1dad2", + "definition_sha256": "15b10f36c0d2b46b5e3d4815babc57bfbebefecde0f6c5579e7e9d666bd1edb9", + "source_commit": "5553dae32ddf5d22592e11e84c8ed10e8c2fbc3e", "dirty": false, - "recorded_at": "2026-09-05T19:58:31Z", + "recorded_at": "2026-09-06T15:08:56Z", "packages": [ "own-analysis", "own-bridge", @@ -14,6 +14,7 @@ "own-diagnostics", "own-ir", "own-lowered", + "own-shadow", "own-syntax" ], "command": "cargo test -p --no-fail-fast, for every workspace member", @@ -21,7 +22,7 @@ "id": "M00", "outcome": "survived", "catchers": [], - "elapsed_seconds": 31.0 + "elapsed_seconds": 1.6 }, "mutations": [ { @@ -31,7 +32,7 @@ "own-bridge/src/lib.rs::verdict::tests::only_error_severity_core_verdicts_are_mapped", "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.2 + "elapsed_seconds": 3.6 }, { "id": "M02", @@ -39,7 +40,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.8 + "elapsed_seconds": 3.9 }, { "id": "M03", @@ -47,7 +48,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.5 + "elapsed_seconds": 3.9 }, { "id": "M04", @@ -55,7 +56,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.5 + "elapsed_seconds": 3.8 }, { "id": "M05", @@ -63,15 +64,15 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.2 + "elapsed_seconds": 3.8 }, { "id": "M06", "outcome": "caught", "catchers": [ - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member" ], - "elapsed_seconds": 3.3 + "elapsed_seconds": 3.8 }, { "id": "M07", @@ -80,15 +81,16 @@ "own-analysis/tests/fact_parity.rs::di_fact_parity", "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.7 + "elapsed_seconds": 3.9 }, { "id": "M08", "outcome": "caught", "catchers": [ - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 3.7 + "elapsed_seconds": 3.8 }, { "id": "M09", @@ -96,7 +98,7 @@ "catchers": [ "own-bridge/src/lib.rs::verdict::tests::malformed_effect_entries_are_skipped_not_coerced" ], - "elapsed_seconds": 3.6 + "elapsed_seconds": 3.9 }, { "id": "M10", @@ -104,15 +106,16 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.6 + "elapsed_seconds": 3.9 }, { "id": "M11", "outcome": "caught", "catchers": [ - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 3.8 + "elapsed_seconds": 4.6 }, { "id": "M12", @@ -121,9 +124,10 @@ "own-analysis/tests/subject.rs::leak_carries_the_acquire_origin", "own-analysis/tests/subject.rs::origin_is_inherited_across_a_move", "own-analysis/tests/subject.rs::param_leak_carries_the_param_origin", - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 4.0 + "elapsed_seconds": 4.8 }, { "id": "M13", @@ -132,15 +136,16 @@ "own-analysis/tests/subject.rs::region_escape_carries_the_source_identity", "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.6 + "elapsed_seconds": 3.9 }, { "id": "M14", "outcome": "caught", "catchers": [ - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 3.5 + "elapsed_seconds": 3.9 }, { "id": "M15", @@ -148,15 +153,16 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.6 + "elapsed_seconds": 3.8 }, { "id": "M16", "outcome": "caught", "catchers": [ - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 3.6 + "elapsed_seconds": 3.8 }, { "id": "M17", @@ -164,15 +170,16 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.4 + "elapsed_seconds": 3.7 }, { "id": "M18", "outcome": "caught", "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member", "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.4 + "elapsed_seconds": 3.8 }, { "id": "M19", @@ -180,7 +187,7 @@ "catchers": [ "own-bridge/src/lib.rs::verdict::tests::only_error_severity_core_verdicts_are_mapped" ], - "elapsed_seconds": 3.4 + "elapsed_seconds": 3.6 }, { "id": "M20", @@ -188,7 +195,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.3 + "elapsed_seconds": 3.6 }, { "id": "M21", @@ -196,7 +203,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.3 + "elapsed_seconds": 3.8 }, { "id": "M22", @@ -204,16 +211,20 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.4 + "elapsed_seconds": 3.6 }, { "id": "M23", "outcome": "caught", "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::a_flow_local_code_without_a_wording_keeps_the_core_message", + "own-bridge/src/lib.rs::verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two", "own-bridge/src/lib.rs::verdict::tests::only_error_severity_core_verdicts_are_mapped", - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/src/lib.rs::verdict::tests::the_capture_route_names_a_non_static_source_it_can_never_be_handed", + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 3.5 + "elapsed_seconds": 3.7 }, { "id": "M24", @@ -221,7 +232,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.4 + "elapsed_seconds": 3.8 }, { "id": "M25", @@ -229,7 +240,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.4 + "elapsed_seconds": 3.9 }, { "id": "M26", @@ -237,23 +248,23 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.4 + "elapsed_seconds": 3.8 }, { "id": "M27", "outcome": "caught", "catchers": [ - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member" ], - "elapsed_seconds": 3.4 + "elapsed_seconds": 3.5 }, { "id": "M28", "outcome": "caught", "catchers": [ - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member" ], - "elapsed_seconds": 3.4 + "elapsed_seconds": 3.6 }, { "id": "M29", @@ -261,16 +272,17 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.4 + "elapsed_seconds": 3.7 }, { "id": "M30", "outcome": "caught", "catchers": [ "own-bridge/src/lib.rs::verdict::tests::di_coercions_match_the_reference", - "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 3.5 + "elapsed_seconds": 3.9 } ] } diff --git a/docs/evidence/p022-cp5-1.json b/docs/evidence/p022-cp5-1.json new file mode 100644 index 00000000..fefda2b8 --- /dev/null +++ b/docs/evidence/p022-cp5-1.json @@ -0,0 +1,475 @@ +{ + "schema": 1, + "comment": "Mutation campaign definition for #259 checkpoint 5.1 (P-022 discipline rules 2 and 3). Each entry is a source mutation: a regex that must match `target` exactly once, its replacement, the Bridge.md rule it attacks, and the tests expected to fail. Replay: python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp5-1.json --run (on a clean tree). The recorded run lives beside this file as p022-cp5-1.result.json; counts are derived from it by scripts/render_checkpoint_status.py, never typed.", + "campaign": "p022-cp5-1", + "description": "#259 cp5.1 messages and evidence: the BR-V4 wording matrix on the bridge (every flow-local split, the OWN025 view sentence, both OWN014 families with their lifetime phrases and the inline-lambda note, every token kind, the three advisories), the analysis-owned DI and effect messages, the BR-V5 evidence slices family by family with their line-guard and short-slice degradations, and the BR-V7 dedup key's new message member. Two mutations attack the CONTROLS rather than the port: they corrupt the recorded oracle wordings in tests/fixtures/unreachable_branches.json and require the Rust controls for the unreachable branches to fail, because a control that survives a drifting oracle record is carrying a literal and only appears to be reading the reference.", + "workspace": "rust", + "control": { + "id": "M00", + "description": "no mutation — the unmutated tree must pass (harness honesty control)" + }, + "mutations": [ + { + "id": "M01", + "rule": "BR-V4", + "description": "BR-V4 ever_released split inverted on the plain OWN001 wording", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\(false, true\\) => \\{\\n(\\s+)format!\\(\\\"IDisposable local '\\{name\\}' may not be disposed on every path \\(leak\\)\\\"\\)", + "replacement": "(false, true) => {\n\\1format!(\"IDisposable local '{name}' is never disposed (leak)\")", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M02", + "rule": "BR-V4", + "description": "BR-V4 pool split ignored: a pooled OWN001 words as a plain local", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "if code == \\\"OWN001\\\" \\{\\n(\\s+)return match \\(pool, ever_released\\) \\{", + "replacement": "if code == \"OWN001\" {\n\\1return match (false, ever_released) {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M03", + "rule": "BR-V4", + "description": "BR-V4 pooled use-after-return wording says 'disposed'", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\\"OWN002\\\" => format!\\(\\\"pooled buffer '\\{name\\}' is used after it is returned to the pool\\\"\\)", + "replacement": "\"OWN002\" => format!(\"pooled buffer '{name}' is used after it is disposed\")", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M04", + "rule": "BR-V4", + "description": "BR-V4 pooled maybe-use wording collapses onto the plain one", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\\"OWN009\\\" => \\{\\n(\\s+)format!\\(\\\"pooled buffer '\\{name\\}' may be used after being returned on some path\\\"\\)", + "replacement": "\"OWN009\" => {\n\\1format!(\"IDisposable local '{name}' may be used after disposal on some path\")", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M05", + "rule": "BR-V4", + "description": "BR-V4 OWN025 view wording drops the logical-length clause", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\\"pooled buffer '\\{name\\}' is viewed at its full length, past the \\\\\\n(\\s+)logical length it was rented for \\(over-read / over-clear\\)\\\"", + "replacement": "\"pooled buffer '{name}' is viewed at its full length (over-read / over-clear)\"", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M06", + "rule": "BR-V4", + "description": "BR-V4 fallback drops the core message it is there to carry", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "_ => format!\\(\\\"IDisposable local '\\{name\\}': \\{core_message\\}\\\"\\),", + "replacement": "_ => format!(\"IDisposable local '{name}'\"),", + "expected_catchers": [ + "own-bridge/src/lib.rs::verdict::tests::a_flow_local_code_without_a_wording_keeps_the_core_message" + ] + }, + { + "id": "M07", + "rule": "BR-V4", + "description": "BR-V4 every DI lifetime takes the singleton nice phrase", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\"scoped\" => \"a DI scoped service\"\\.to_owned\\(\\),", + "replacement": "\"scoped\" => \"a DI singleton (application-lifetime) service\".to_owned(),", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M08", + "rule": "BR-V4", + "description": "BR-V4 an unrecognised DI lifetime is hidden behind a generic phrase", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "other => format!\\(\"a DI \\{other\\} service\"\\),", + "replacement": "other => format!(\"a DI service ({other})\"),", + "expected_catchers": [ + "own-bridge/src/lib.rs::verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two" + ] + }, + { + "id": "M09", + "rule": "BR-V4", + "description": "BR-V4 lambda note appended whether or not the handler is a lambda", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "fn lambda_note\\(rec: &Obj\\) -> &'static str \\{\\n(\\s+)if truthy\\(rec\\.get\\(\\\"lambda\\\"\\)\\) \\{", + "replacement": "fn lambda_note(rec: &Obj) -> &'static str {\n\\1if !truthy(rec.get(\"lambda\")) {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M10", + "rule": "BR-V4", + "description": "BR-V4 lambda note never appended", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "(fn lambda_note\\(rec: &Obj\\) -> &'static str \\{\\n\\s+if truthy\\(rec\\.get\\(\\\"lambda\\\"\\)\\) \\{\\n\\s+)LAMBDA_NOTE", + "replacement": "\\1\"\"", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M11", + "rule": "BR-V4", + "description": "BR-V4 the capture route words every source as static", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "let named = rec\\.get\\(\\\"source\\\"\\)\\.map_or_else\\(\\|\\| \\\"\\?\\\"\\.to_owned\\(\\), py_str\\);\\n(\\s+)format!\\(\\\"a longer-lived source \\('\\{named\\}'\\)\\\"\\)", + "replacement": "let named = rec.get(\"source\").map_or_else(|| \"?\".to_owned(), py_str);\n\\1let _ = named;\n\\1\"a static (process-lived) event source\".to_owned()", + "expected_catchers": [ + "own-bridge/src/lib.rs::verdict::tests::the_capture_route_names_a_non_static_source_it_can_never_be_handed" + ] + }, + { + "id": "M12", + "rule": "BR-V4", + "description": "BR-V4 timer wording drops the handler", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\"timer \\'\\{event\\}\\' \\(handler \\'\\{handler\\}\\'\\) is started but never stopped or \\\\\\n(\\s+)detached", + "replacement": "\"timer '{event}' is started but never stopped or \\\\\n\\1detached", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M13", + "rule": "BR-V4", + "description": "BR-V4 disposable-field `type` parenthetical emitted on presence, not truthiness", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\.get\\(\\\"type\\\"\\)\\n(\\s+)\\.filter\\(\\|v\\| truthy\\(Some\\(v\\)\\)\\)", + "replacement": ".get(\"type\")\n\\1.filter(|v| !v.is_null())", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M14", + "rule": "BR-V4", + "description": "BR-V4 local-disposable wording collapses onto the field wording", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\"local IDisposable \\'\\{event\\}\\'\\{of_type\\} is created but never disposed \\(leak\\)\"", + "replacement": "\"IDisposable field '{event}'{of_type} is created but never disposed (leak)\"", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M15", + "rule": "BR-V4", + "description": "BR-V4 an injected Subscribe() result takes the provable-leak wording", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\"subscribe\" if injected => format!\\(", + "replacement": "\"subscribe\" if false => format!(", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M16", + "rule": "BR-V4", + "description": "BR-V4 pool token wording says 'disposed'", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "format!\\(\"pooled buffer \\'\\{event\\}\\' is rented but never returned to the pool \\(leak\\)\"\\)", + "replacement": "format!(\"pooled buffer '{event}' is rented but never disposed (leak)\")", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M17", + "rule": "BR-V4", + "description": "BR-V4 an injected subscription takes the provable-leak wording", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "_ if injected => format!\\(\\n(\\s+)\\\"event '\\{event\\}' is subscribed \\(handler '\\{handler\\}'\\) but never \\\\", + "replacement": "_ if false => format!(\n\\1\"event '{event}' is subscribed (handler '{handler}') but never \\\\", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M18", + "rule": "BR-V4", + "description": "BR-V4 OWN050 message drops the event it cannot verify", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "its declaring type is an unresolved reference \\\\", + "replacement": "its declaring type is unresolved \\\\", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M19", + "rule": "BR-V4", + "description": "BR-V4 OWN051 message drops the inferred contract", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\"cannot verify whether \\'\\{\\}\\' takes ownership of \\'\\{\\}\\' \\(inferred contract: \\{\\}\\); \\\\", + "replacement": "\"cannot verify whether '{}' takes ownership of '{}' (inferred contract: {:.0}); \\\\", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M20", + "rule": "BR-V4", + "description": "BR-V4 OWN052 message drops the solve-failure reason", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\"interprocedural summary inference failed \\(\\{reason\\}\\); method summaries \\\\", + "replacement": "\"interprocedural summary inference failed; method summaries \\\\", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M21", + "rule": "BR-V4", + "description": "di.py chain separator changed (the retention path in the message)", + "target": "rust/crates/own-analysis/src/di.rs", + "pattern": "let chain = path\\.join\\(\" -> \"\\);", + "replacement": "let chain = path.join(\" > \");", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M22", + "rule": "BR-V4", + "description": "di.py consumed tail names the service instead of dropping an unknown impl", + "target": "rust/crates/own-analysis/src/di.rs", + "pattern": "let owner = if ctor_type\\.is_empty\\(\\) \\|\\| ctor_type == \"\\?\" \\{\\n(\\s+)\"the constructor\"\\.to_owned\\(\\)", + "replacement": "let owner = if ctor_type.is_empty() {\n\\1\"the constructor\".to_owned()", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M23", + "rule": "BR-V4", + "description": "di.py registration tail emitted even when the primary IS the registration", + "target": "rust/crates/own-analysis/src/di.rs", + "pattern": "if site_line >= 1 && reg_line >= 1 \\{\\n(\\s+)format!\\(\\\" \\[singleton registered at \\{reg_file\\}:\\{reg_line\\}\\]\\\"\\)", + "replacement": "if reg_line >= 1 {\n\\1format!(\" [singleton registered at {reg_file}:{reg_line}]\")", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M24", + "rule": "BR-V4", + "description": "effects.py kind phrase collapses onto the object-literal one", + "target": "rust/crates/own-analysis/src/effect.rs", + "pattern": "\"array\" => \"an array literal\",", + "replacement": "\"array\" => \"an object literal\",", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M25", + "rule": "BR-V4", + "description": "effects.py `via` clause emitted for a single-element chain", + "target": "rust/crates/own-analysis/src/effect.rs", + "pattern": "let via = if self\\.chain\\.len\\(\\) > 1 \\{", + "replacement": "let via = if !self.chain.is_empty() {", + "expected_catchers": [ + "own-analysis/src/lib.rs::effect::tests::the_via_clause_is_omitted_for_a_single_hop_chain" + ] + }, + { + "id": "M26", + "rule": "BR-V5", + "description": "BR-V5 flow-local violation labels swapped between OWN002 and OWN003", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\"OWN002\" => Some\\(\"used here after it was released/returned\"\\),", + "replacement": "\"OWN002\" => Some(\"released/returned here a second time\"),", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M27", + "rule": "BR-V5", + "description": "BR-V5 flow-local origin always words as an acquire, never a rent", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "let origin = if pool \\{\\n(\\s+)format!\\(\\\"rented '\\{name\\}' here\\\"\\)", + "replacement": "let origin = if false {\n\\1format!(\"rented '{name}' here\")", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M28", + "rule": "BR-V5", + "description": "BR-V5 flow-local slice keeps a step whose line is unknown", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "if acquire < 1 \\|\\| dline < 1 \\|\\| dline == acquire \\{\\n(\\s+)return Vec::new\\(\\);", + "replacement": "if dline == acquire {\n\\1return Vec::new();", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M29", + "rule": "BR-V5", + "description": "BR-V5 flow-local slice emitted when both sites coincide", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "if acquire < 1 \\|\\| dline < 1 \\|\\| dline == acquire \\{", + "replacement": "if acquire < 1 || dline < 1 {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M30", + "rule": "BR-V5", + "description": "BR-V5 DI path labels the last hop as a pass-through `via`", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\} else if i == last \\{\\n(\\s+)format!\\(\\\"\\{end_label\\} '\\{name\\}'\\\"\\)", + "replacement": "} else if false {\n\\1format!(\"{end_label} '{name}'\")", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M31", + "rule": "BR-V5", + "description": "BR-V5 DI path emits a hop whose registration site is unknown", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "let \\(file, line\\) = loc\\.get\\(name\\.as_str\\(\\)\\)\\?;", + "replacement": "let (file, line) = loc.get(name.as_str()).cloned().unwrap_or((\"?\".to_owned(), 0));\n let (file, line) = (&file, &line);", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M32", + "rule": "BR-V5", + "description": "BR-V5 DI end label ignores the family (every path 'captures scoped')", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "b\"DI004\" => \"leaks transient IDisposable\",", + "replacement": "b\"DI004\" => \"captures scoped service\",", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M33", + "rule": "BR-V5", + "description": "BR-V5 DI consumer related emitted even with an unknown ctor line", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\} else if c\\.consumed_line >= 1 \\{", + "replacement": "} else if c.consumed_line >= 0 {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M34", + "rule": "BR-V5", + "description": "BR-V5 DI consumer related names an unknown impl type", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "let owner = if c\\.consumed_type\\.is_empty\\(\\) \\|\\| c\\.consumed_type == \"\\?\" \\{", + "replacement": "let owner = if c.consumed_type.is_empty() {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M35", + "rule": "BR-V5", + "description": "BR-V5 DI004/DI005 registration related emitted regardless of the site", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "if c\\.site_line >= 1 && c\\.reg_line >= 1 \\{", + "replacement": "if c.reg_line >= 1 {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M36", + "rule": "BR-V5", + "description": "BR-V5 OWN014 escape slice emitted with its source hop at an unknown line", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "if let Some\\(\\(sf, sl\\)\\) = key\\.and_then\\(\\|k\\| svc_loc\\.get\\(&k\\)\\)\\.filter\\(\\|\\(_, l\\)\\| \\*l >= 1\\) \\{", + "replacement": "if let Some((sf, sl)) = key.and_then(|k| svc_loc.get(&k)) {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M37", + "rule": "BR-V5", + "description": "BR-V5 OWN014 escape slice built from a subscribe site with no line", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "if anchor >= 1 \\{\\n(\\s+)// `svc_loc\\.get\\(st\\)` in the reference", + "replacement": "if anchor >= -1 {\n\\1// `svc_loc.get(st)` in the reference", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M38", + "rule": "BR-V5", + "description": "BR-V5 effect slice emitted when the mint line is unknown", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "if s\\.line >= 1 && s\\.decl_line >= 1 \\{", + "replacement": "if s.line >= 1 {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M39", + "rule": "BR-V5", + "description": "BR-V5 effect slice steps reversed (mint before re-run)", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "format!\\(\"effect re-runs here on \\'\\{\\}\\'\", s\\.dep\\),", + "replacement": "format!(\"'{}' gets a fresh identity here — stabilise with useMemo\", s.origin),", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M40", + "rule": "BR-V7", + "description": "BR-V7 dedup key drops the message member cp5 added", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "(\\n\\s+)f\\.message\\.clone\\(\\),(\\n\\s+f\\.kind\\.clone\\(\\),)", + "replacement": "\\1String::new(),\\2", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M41", + "rule": "BR-V4", + "description": "the recorded oracle wording drifts and the bridge control does not notice (i.e. it carries a literal, not the reference's answer)", + "target": "tests/fixtures/unreachable_branches.json", + "pattern": "\"own014_di_transient\": \"event \\'src\\.E\\'", + "replacement": "\"own014_di_transient\": \"event 'src.X'", + "expected_catchers": [ + "own-bridge/src/lib.rs::verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two" + ] + }, + { + "id": "M42", + "rule": "BR-V4", + "description": "the recorded oracle wording drifts and the effect control does not notice (the own-analysis half of the same claim)", + "target": "tests/fixtures/unreachable_branches.json", + "pattern": "\\(via cfg -> opts\\)", + "replacement": "(via opts)", + "expected_catchers": [ + "own-analysis/src/lib.rs::effect::tests::the_via_clause_is_omitted_for_a_single_hop_chain" + ] + } + ] +} diff --git a/docs/evidence/p022-cp5-1.result.json b/docs/evidence/p022-cp5-1.result.json new file mode 100644 index 00000000..b0702d3a --- /dev/null +++ b/docs/evidence/p022-cp5-1.result.json @@ -0,0 +1,376 @@ +{ + "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-cp5-1", + "definition": "docs/evidence/p022-cp5-1.json", + "definition_sha256": "2b4651e54f2862f5b7bdce8f2f682a2bdbd6826677f383736ed7f359ba334d50", + "source_commit": "cdff105ca43ec5b1ced2bd25a8216f4f7b65deb3", + "dirty": false, + "recorded_at": "2026-09-06T17:30:57Z", + "packages": [ + "own-analysis", + "own-bridge", + "own-cfg", + "own-diagnostics", + "own-ir", + "own-lowered", + "own-shadow", + "own-syntax" + ], + "command": "cargo test -p --no-fail-fast, for every workspace member", + "control": { + "id": "M00", + "outcome": "survived", + "catchers": [], + "elapsed_seconds": 1.6 + }, + "mutations": [ + { + "id": "M01", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.9 + }, + { + "id": "M02", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.1 + }, + { + "id": "M03", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.9 + }, + { + "id": "M04", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.5 + }, + { + "id": "M05", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M06", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::a_flow_local_code_without_a_wording_keeps_the_core_message" + ], + "elapsed_seconds": 4.5 + }, + { + "id": "M07", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.2 + }, + { + "id": "M08", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two" + ], + "elapsed_seconds": 4.0 + }, + { + "id": "M09", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two", + "own-bridge/src/lib.rs::verdict::tests::the_capture_route_names_a_non_static_source_it_can_never_be_handed", + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte", + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.1 + }, + { + "id": "M10", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.2 + }, + { + "id": "M11", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::the_capture_route_names_a_non_static_source_it_can_never_be_handed" + ], + "elapsed_seconds": 4.8 + }, + { + "id": "M12", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M13", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.5 + }, + { + "id": "M14", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.1 + }, + { + "id": "M15", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.7 + }, + { + "id": "M16", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.5 + }, + { + "id": "M17", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte", + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M18", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte", + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M19", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.3 + }, + { + "id": "M20", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte", + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 4.8 + }, + { + "id": "M21", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte", + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 4.8 + }, + { + "id": "M22", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.1 + }, + { + "id": "M23", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.0 + }, + { + "id": "M24", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.0 + }, + { + "id": "M25", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::effect::tests::the_via_clause_is_omitted_for_a_single_hop_chain" + ], + "elapsed_seconds": 4.7 + }, + { + "id": "M26", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.7 + }, + { + "id": "M27", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.0 + }, + { + "id": "M28", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.1 + }, + { + "id": "M29", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.2 + }, + { + "id": "M30", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte", + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M31", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M32", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M33", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.2 + }, + { + "id": "M34", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.1 + }, + { + "id": "M35", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.0 + }, + { + "id": "M36", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.2 + }, + { + "id": "M37", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.1 + }, + { + "id": "M38", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.2 + }, + { + "id": "M39", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.2 + }, + { + "id": "M40", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 4.1 + }, + { + "id": "M41", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two" + ], + "elapsed_seconds": 4.2 + }, + { + "id": "M42", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::effect::tests::the_via_clause_is_omitted_for_a_single_hop_chain" + ], + "elapsed_seconds": 3.7 + } + ] +} diff --git a/docs/evidence/p022-cp5-2.json b/docs/evidence/p022-cp5-2.json new file mode 100644 index 00000000..0addfb2c --- /dev/null +++ b/docs/evidence/p022-cp5-2.json @@ -0,0 +1,127 @@ +{ + "schema": 1, + "comment": "Mutation campaign definition for #259 checkpoint 5.2 (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-cp5-2.json --run (on a clean tree). The recorded run lives beside this file as p022-cp5-2.result.json; counts are derived from it by scripts/render_checkpoint_status.py, never typed.", + "campaign": "p022-cp5-2", + "description": "#259 cp5.2 refusal text: the core message layer own-cfg now carries for the resolver, the seam own-analysis reads it through, and the CPython `repr` the BR-V3 map-or-raise text quotes it with — including the quote switch cp4's cut hid. Plus the shadow engine's verdict capture, which stopped being a partial projection in this checkpoint.", + "workspace": "rust", + "control": { + "id": "M00", + "description": "no mutation — the unmutated tree must pass (harness honesty control)" + }, + "mutations": [ + { + "id": "M01", + "rule": "BR-V3", + "description": "the resolver's OWN030 message drops the name it interpolates", + "target": "rust/crates/own-cfg/src/builder.rs", + "pattern": "format!\\(\"undefined name \\'\\{name\\}\\'\"\\)", + "replacement": "String::from(\"undefined name\")", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M02", + "rule": "BR-V3", + "description": "the resolver emits a message-less diagnostic again (the title stands in)", + "target": "rust/crates/own-cfg/src/builder.rs", + "pattern": "self\\.diags\\.push\\(Diag::with_message\\(\\n\\s+\"OWN030\",\\n\\s+line,\\n\\s+format!\\(\"undefined name \\'\\{name\\}\\'\"\\),\\n\\s+\\)\\);", + "replacement": "self.diags.push(Diag::new(\"OWN030\", line));", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M03", + "rule": "BR-V3", + "description": "own-analysis ignores the message own-cfg carries and substitutes the title", + "target": "rust/crates/own-analysis/src/check.rs", + "pattern": "let msg = d\\n\\s+\\.message\\n\\s+\\.as_deref\\(\\)\\n\\s+\\.unwrap_or_else\\(\\|\\| title\\(d\\.code\\)\\.unwrap_or\\(d\\.code\\)\\);", + "replacement": "let msg = title(d.code).unwrap_or(d.code);", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M04", + "rule": "BR-V3", + "description": "py_repr always single-quotes (cp4's placeholder, the bug cp5.2 found)", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "let quote = if s\\.contains\\('\\\\''\\) && !s\\.contains\\('\\\"'\\) \\{\\n\\s+'\\\"'\\n\\s+\\} else \\{\\n\\s+'\\\\''\\n\\s+\\};", + "replacement": "let quote = '\\'';", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch" + ] + }, + { + "id": "M05", + "rule": "BR-V3", + "description": "py_repr's quote switch inverted (double unless a quote is present)", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "let quote = if s\\.contains\\('\\\\''\\) && !s\\.contains\\('\\\"'\\) \\{", + "replacement": "let quote = if !s.contains('\\'') && !s.contains('\"') {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch" + ] + }, + { + "id": "M06", + "rule": "BR-V3", + "description": "py_repr leaves a backslash unescaped", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "'\\\\\\\\' => out\\.push_str\\(\\\"\\\\\\\\\\\\\\\\\\\"\\),", + "replacement": "'\\\\\\\\' => out.push('\\\\\\\\'),", + "expected_catchers": [ + "own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch" + ] + }, + { + "id": "M07", + "rule": "BR-V3", + "description": "py_repr leaves an ASCII control character raw", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "c if c\\.is_ascii_control\\(\\) => \\{\\n(?:.*\\n)*?\\s+for shift in \\[4_u32, 0\\] \\{\\n\\s+let nibble = \\(byte >> shift\\) & 0xf;\\n\\s+out\\.push\\(char::from_digit\\(nibble, 16\\)\\.unwrap_or\\('0'\\)\\);\\n\\s+\\}\\n\\s+\\}", + "replacement": "c if c.is_ascii_control() => out.push(c),", + "expected_catchers": [ + "own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch" + ] + }, + { + "id": "M08", + "rule": "BR-V3", + "description": "py_repr renders an absent subject as an empty string, not None", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "return \"None\"\\.to_owned\\(\\);", + "replacement": "return \"''\".to_owned();", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch" + ] + }, + { + "id": "M09", + "rule": "engine protocol", + "description": "the shadow capture emits an empty evidence slice while declaring the layer full", + "target": "rust/crates/own-shadow/src/engine.rs", + "pattern": "\\(\"related\", steps\\(&f\\.related\\)\\),", + "replacement": "(\"related\", steps(&[])),", + "expected_catchers": [ + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ] + }, + { + "id": "M10", + "rule": "engine protocol", + "description": "the shadow capture drops a member while still declaring the layer full", + "target": "rust/crates/own-shadow/src/engine.rs", + "pattern": "\\(\"message\", Json::Str\\(f\\.message\\.clone\\(\\)\\)\\),\\n", + "replacement": "", + "expected_catchers": [ + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "own-shadow/tests/engine.rs::a_projection_names_exactly_the_members_it_carries" + ] + } + ] +} diff --git a/docs/evidence/p022-cp5-2.result.json b/docs/evidence/p022-cp5-2.result.json new file mode 100644 index 00000000..e8551ec6 --- /dev/null +++ b/docs/evidence/p022-cp5-2.result.json @@ -0,0 +1,119 @@ +{ + "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-cp5-2", + "definition": "docs/evidence/p022-cp5-2.json", + "definition_sha256": "8641e3ff34598123a194ab64111f033c345f6bb1846544c76c62a59230d39c08", + "source_commit": "2a7ff6d4ab2877b930f79f09bc6f55bfbffd094e", + "dirty": false, + "recorded_at": "2026-09-06T15:54:56Z", + "packages": [ + "own-analysis", + "own-bridge", + "own-cfg", + "own-diagnostics", + "own-ir", + "own-lowered", + "own-shadow", + "own-syntax" + ], + "command": "cargo test -p --no-fail-fast, for every workspace member", + "control": { + "id": "M00", + "outcome": "survived", + "catchers": [], + "elapsed_seconds": 8.4 + }, + "mutations": [ + { + "id": "M01", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 5.0 + }, + { + "id": "M02", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 4.5 + }, + { + "id": "M03", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 4.7 + }, + { + "id": "M04", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch", + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M05", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch", + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M06", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch" + ], + "elapsed_seconds": 4.3 + }, + { + "id": "M07", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M08", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch", + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 4.9 + }, + { + "id": "M09", + "outcome": "caught", + "catchers": [ + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 4.3 + }, + { + "id": "M10", + "outcome": "caught", + "catchers": [ + "own-shadow/tests/engine.rs::a_projection_names_exactly_the_members_it_carries", + "own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture" + ], + "elapsed_seconds": 4.1 + } + ] +} diff --git a/docs/evidence/p022-cp5-3.json b/docs/evidence/p022-cp5-3.json new file mode 100644 index 00000000..a5097145 --- /dev/null +++ b/docs/evidence/p022-cp5-3.json @@ -0,0 +1,332 @@ +{ + "schema": 1, + "comment": "Mutation campaign definition for #259 checkpoint 5.3 (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-cp5-3.json --run (on a clean tree). The recorded run lives beside this file as p022-cp5-3.result.json; counts are derived from it by scripts/render_checkpoint_status.py, never typed.", + "campaign": "p022-cp5-3", + "description": "#259 cp5.3 rendered surfaces (BR-V9): the three line-per-finding formats and their host-severity pass-through, every workflow-command escaping rule in both directions and in the order they compose, and the bridge's SARIF log — level mapping, sorted and deduplicated rule catalogue with titles, the ownirSchemaVersion stamp, region omission and startColumn, backslash normalisation, conditional properties, both evidence projections (including the one place reusing the core's builder would be a bug), suppressions, and the result order.", + "workspace": "rust", + "control": { + "id": "M00", + "description": "no mutation — the unmutated tree must pass (harness honesty control)" + }, + "mutations": [ + { + "id": "M01", + "rule": "BR-V9", + "description": "the human line drops the [resource:] tag", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\"\\{\\}:\\{\\}: \\{severity\\}: \\[\\{\\}\\] \\{\\} \\[resource: \\{\\}\\]\"", + "replacement": "\"{}:{}: {severity}: [{}] {}{}\"", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M02", + "rule": "BR-V9", + "description": "the human line hard-codes error, ignoring the host severity", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\"\\{\\}:\\{\\}: \\{severity\\}: \\[\\{\\}\\] \\{\\} \\[resource: \\{\\}\\]\",\\n(\\s+)f\\.file, f\\.line, f\\.code, f\\.message, f\\.kind", + "replacement": "\"{}:{}: error: [{}] {} [resource: {}]\",\n\\1f.file, f.line, f.code, f.message, f.kind", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M03", + "rule": "BR-V9", + "description": "the github annotation swaps its file and line properties", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\"::\\{severity\\} file=\\{\\},line=\\{\\},title=\\{\\}::\\{\\}\"", + "replacement": "\"::{severity} line={1},file={0},title={2}::{3}\"", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M04", + "rule": "BR-V9", + "description": "the github annotation hard-codes the error level", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\"::\\{severity\\} file=\\{\\},line=\\{\\},title=\\{\\}::\\{\\}\",", + "replacement": "\"::error file={},line={},title={}::{}\",", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M05", + "rule": "BR-V9", + "description": "workflow-command data leaves '%' unescaped", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "s\\.replace\\('%', \\\"%25\\\"\\)", + "replacement": "s.to_owned()", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M06", + "rule": "BR-V9", + "description": "workflow-command data leaves CR unescaped", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\\s*\\.replace\\('\\\\r', \\\"%0D\\\"\\)", + "replacement": "", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M07", + "rule": "BR-V9", + "description": "workflow-command data leaves LF unescaped", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\\s*\\.replace\\('\\\\n', \\\"%0A\\\"\\)", + "replacement": "", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M08", + "rule": "BR-V9", + "description": "a workflow-command property value leaves ':' unescaped", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\\.replace\\(':', \\\"%3A\\\"\\)", + "replacement": "", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M09", + "rule": "BR-V9", + "description": "a workflow-command property value leaves ',' unescaped", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\\.replace\\(',', \\\"%2C\\\"\\)", + "replacement": "", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M10", + "rule": "BR-V9", + "description": "a property value escapes its separators BEFORE the data, double-escaping the %", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "esc_data\\(s\\)\\.replace\\(':', \\\"%3A\\\"\\)\\.replace\\(',', \\\"%2C\\\"\\)", + "replacement": "esc_data(&s.replace(':', \"%3A\").replace(',', \"%2C\"))", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M11", + "rule": "BR-V9", + "description": "the msbuild line uses the human separator instead of parentheses", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\"\\{\\}\\(\\{\\}\\): \\{severity\\} \\{\\}: \\{\\} \\[resource: \\{\\}\\]\"", + "replacement": "\"{}:{}: {severity} {}: {} [resource: {}]\"", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M12", + "rule": "BR-V9", + "description": "the msbuild line hard-codes error, ignoring the host severity", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\"\\{\\}\\(\\{\\}\\): \\{severity\\} \\{\\}: \\{\\} \\[resource: \\{\\}\\]\",", + "replacement": "\"{}({}): error {}: {} [resource: {}]\",", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M13", + "rule": "BR-V9", + "description": "an unknown format falls back to the github annotation, not the human line", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\"msbuild\" => render_msbuild\\(f, severity\\),\\n(\\s+)_ => render_human\\(f, severity\\),", + "replacement": "\"msbuild\" => render_msbuild(f, severity),\n\\1_ => render_github(f, severity),", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M14", + "rule": "BR-V6", + "description": "an advisory renders as a warning instead of SARIF's note", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "if f\\.advisory \\{\\n(\\s+)return \"note\"\\.to_owned\\(\\);", + "replacement": "if false {\n\\1return \"note\".to_owned();", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M15", + "rule": "BR-V6", + "description": "the host severity no longer downgrades a provable leak", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "if severity == \"warning\" \\|\\| f\\.severity\\.as_deref\\(\\) == Some\\(\"warning\"\\) \\{", + "replacement": "if f.severity.as_deref() == Some(\"warning\") {", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M16", + "rule": "BR-V6", + "description": "an intrinsic warning tier is ignored and renders as error", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "if severity == \"warning\" \\|\\| f\\.severity\\.as_deref\\(\\) == Some\\(\"warning\"\\) \\{", + "replacement": "if severity == \"warning\" {", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M17", + "rule": "BR-V9", + "description": "the SARIF rule catalogue is not sorted", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "codes\\.sort_unstable\\(\\);\\n", + "replacement": "", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M18", + "rule": "BR-V9", + "description": "the SARIF rule catalogue keeps duplicate codes", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "codes\\.dedup\\(\\);\\n", + "replacement": "", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M19", + "rule": "BR-V9", + "description": "a rule's shortDescription is the code, never its title", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "text: title\\(code\\)\\.unwrap_or\\(code\\)\\.to_owned\\(\\),", + "replacement": "text: code.to_owned(),", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M20", + "rule": "BR-V9", + "description": "the driver drops the ownirSchemaVersion stamp", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "#\\[serde\\(rename = \"ownirSchemaVersion\"\\)\\]\\n(\\s+)ownir_schema_version: u32,", + "replacement": "#[serde(rename = \"ownirSchemaVersion\", skip_serializing)]\n\\1ownir_schema_version: u32,", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M21", + "rule": "BR-V9", + "description": "a region is emitted for a file-level (line < 1) finding", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "region: \\(f\\.line >= 1\\)\\.then_some\\(Region \\{", + "replacement": "region: (f.line >= -1).then_some(Region {", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M22", + "rule": "BR-V9", + "description": "startColumn is emitted as null when the producer reported none", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "#\\[serde\\(rename = \"startColumn\", skip_serializing_if = \"Option::is_none\"\\)\\]", + "replacement": "#[serde(rename = \"startColumn\")]", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M23", + "rule": "BR-V9", + "description": "the artifact URI keeps a Windows backslash", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "uri: f\\.file\\.replace\\(\\'\\\\\\\\\\', \"/\"\\),", + "replacement": "uri: f.file.clone(),", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M24", + "rule": "BR-V9", + "description": "an empty component/event/handler is emitted as a property", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "fn optional\\(value: &str\\) -> Option \\{\\n(\\s+)\\(!value\\.is_empty\\(\\)\\)\\.then\\(\\|\\| value\\.to_owned\\(\\)\\)", + "replacement": "fn optional(value: &str) -> Option {\n\\1Some(value.to_owned())", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M25", + "rule": "BR-V9", + "description": "relatedLocations adopts the core's empty-file filter, dropping a step the bridge emits", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\\.filter\\(\\|\\(_, line, _\\)\\| \\*line >= 1\\)", + "replacement": ".filter(|(file, line, _)| *line >= 1 && !file.is_empty())", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M26", + "rule": "BR-V9", + "description": "relatedLocations keeps a step whose line is unknown (defensive: no facts document can build such a step)", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "\\.filter\\(\\|\\(_, line, _\\)\\| \\*line >= 1\\)\\n", + "replacement": "", + "expected_catchers": [ + "own-bridge/src/lib.rs::render::tests::a_related_step_with_no_line_is_dropped" + ] + }, + { + "id": "M27", + "rule": "BR-V9", + "description": "codeFlows is never built from the ordered slice", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": " code_flow\\(&steps\\)\\n\\}", + "replacement": " let _ = steps;\n Vec::new()\n}", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M28", + "rule": "BR-V9", + "description": "a suppressed finding loses its suppressions array", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "suppressions: f\\n(\\s+)\\.ignore_reason\\n(\\s+)\\.as_ref\\(\\)", + "replacement": "suppressions: f\n\\1.ignore_reason\n\\2.as_ref()\n\\2.filter(|_| false)", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M29", + "rule": "BR-V8", + "description": "SARIF results are sorted by code, destroying the bridge's own order", + "target": "rust/crates/own-bridge/src/render.rs", + "pattern": "results: findings\\.iter\\(\\)\\.map\\(\\|f\\| sarif_result\\(f, severity\\)\\)\\.collect\\(\\),", + "replacement": "results: {\n let mut rs: Vec<_> = findings.iter().map(|f| sarif_result(f, severity)).collect();\n rs.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));\n rs\n },", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + } + ] +} diff --git a/docs/evidence/p022-cp5-3.result.json b/docs/evidence/p022-cp5-3.result.json new file mode 100644 index 00000000..2a063acf --- /dev/null +++ b/docs/evidence/p022-cp5-3.result.json @@ -0,0 +1,261 @@ +{ + "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-cp5-3", + "definition": "docs/evidence/p022-cp5-3.json", + "definition_sha256": "6b064c3d76eae07a0ec4a61bf5684f9a21b36213c7ecda694e3617c7e228ba5d", + "source_commit": "ceb8521557128bddca96ea6addb817231fde4f56", + "dirty": false, + "recorded_at": "2026-09-06T16:39:22Z", + "packages": [ + "own-analysis", + "own-bridge", + "own-cfg", + "own-diagnostics", + "own-ir", + "own-lowered", + "own-shadow", + "own-syntax" + ], + "command": "cargo test -p --no-fail-fast, for every workspace member", + "control": { + "id": "M00", + "outcome": "survived", + "catchers": [], + "elapsed_seconds": 1.7 + }, + "mutations": [ + { + "id": "M01", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M02", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.6 + }, + { + "id": "M03", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.7 + }, + { + "id": "M04", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.8 + }, + { + "id": "M05", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.7 + }, + { + "id": "M06", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.5 + }, + { + "id": "M07", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.3 + }, + { + "id": "M08", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.7 + }, + { + "id": "M09", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.5 + }, + { + "id": "M10", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.3 + }, + { + "id": "M11", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.8 + }, + { + "id": "M12", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.8 + }, + { + "id": "M13", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.8 + }, + { + "id": "M14", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.7 + }, + { + "id": "M15", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.2 + }, + { + "id": "M16", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.4 + }, + { + "id": "M17", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.8 + }, + { + "id": "M18", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.7 + }, + { + "id": "M19", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.6 + }, + { + "id": "M20", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.2 + }, + { + "id": "M21", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.3 + }, + { + "id": "M22", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.2 + }, + { + "id": "M23", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.3 + }, + { + "id": "M24", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.3 + }, + { + "id": "M25", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.4 + }, + { + "id": "M26", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::render::tests::a_related_step_with_no_line_is_dropped" + ], + "elapsed_seconds": 3.8 + }, + { + "id": "M27", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.8 + }, + { + "id": "M28", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.4 + }, + { + "id": "M29", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 4.0 + } + ] +} diff --git a/docs/evidence/p022-shadow-cp2.json b/docs/evidence/p022-shadow-cp2.json index 37847a90..1dfbfdd8 100644 --- a/docs/evidence/p022-shadow-cp2.json +++ b/docs/evidence/p022-shadow-cp2.json @@ -106,13 +106,14 @@ }, { "id": "M35", - "description": "the port declares its verdict layer FULL while it is at the checkpoint-4 projection", + "description": "the port declares its verdict layer FULL while emitting a short record (the over-claim that became reachable when the layer stopped being partial, #259 cp5.1/5.2)", "rule": "engine protocol: a projection that over-claims is exactly what the field exists to prevent", "target": "rust/crates/own-shadow/src/engine.rs", - "pattern": "\\ \\ \\ \\ let\\ projection\\ =\\ partial_projection\\(\\&VERDICT_MEMBERS,\\ VERDICT_PROJECTION_REASON\\);", - "replacement": " let projection = full_projection();", + "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\(\"message\",\\ Json::Str\\(f\\.message\\.clone\\(\\)\\)\\),\\n", + "replacement": "", "expected_catchers": [ - "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" + "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture", + "rust-engine/tests/engine.rs::a_projection_names_exactly_the_members_it_carries" ] }, { @@ -123,7 +124,7 @@ "pattern": "\\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\(\"column\",\\ f\\.column\\.map_or\\(Json::Null,\\ Json::Int\\)\\),", "replacement": "", "expected_catchers": [ - "rust-engine/tests/engine.rs::a_partial_projection_names_exactly_the_members_it_carries", + "rust-engine/tests/engine.rs::a_projection_names_exactly_the_members_it_carries", "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" ] }, diff --git a/docs/evidence/p022-shadow-cp2.result.json b/docs/evidence/p022-shadow-cp2.result.json index 06ddcaf2..0a30b370 100644 --- a/docs/evidence/p022-shadow-cp2.result.json +++ b/docs/evidence/p022-shadow-cp2.result.json @@ -3,10 +3,10 @@ "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-shadow-cp2", "definition": "docs/evidence/p022-shadow-cp2.json", - "definition_sha256": "fdeb494e11d5169f07bafdda29fc1338ab99b78243505043cc82607e0e7f5a60", - "source_commit": "0cdbd0f4410bb5ee4a418337f567e515f4146b3b", + "definition_sha256": "bd3fd24d65f539972cd035b45ea55462c617b34e3b82c826a5c10d9dc801a842", + "source_commit": "d726b06192c5fd76c94b02fab1011f55a453c14c", "dirty": false, - "recorded_at": "2026-09-06T13:22:33Z", + "recorded_at": "2026-09-06T16:04:49Z", "layers": [ "python", "rust-repro", @@ -18,7 +18,7 @@ "id": "M00", "outcome": "survived", "catchers": [], - "elapsed_seconds": 1.7 + "elapsed_seconds": 5.2 }, "mutations": [ { @@ -28,7 +28,7 @@ "python::artifact-golden", "python::capture-verify" ], - "elapsed_seconds": 0.5 + "elapsed_seconds": 0.6 }, { "id": "M32", @@ -36,7 +36,7 @@ "catchers": [ "python::artifact-golden" ], - "elapsed_seconds": 1.5 + "elapsed_seconds": 1.9 }, { "id": "M33", @@ -44,7 +44,7 @@ "catchers": [ "python::structural-control" ], - "elapsed_seconds": 1.5 + "elapsed_seconds": 2.0 }, { "id": "M34", @@ -52,21 +52,22 @@ "catchers": [ "python::structural-control" ], - "elapsed_seconds": 1.5 + "elapsed_seconds": 1.7 }, { "id": "M35", "outcome": "caught", "catchers": [ + "rust-engine/tests/engine.rs::a_projection_names_exactly_the_members_it_carries", "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 1.9 + "elapsed_seconds": 2.1 }, { "id": "M36", "outcome": "caught", "catchers": [ - "rust-engine/tests/engine.rs::a_partial_projection_names_exactly_the_members_it_carries", + "rust-engine/tests/engine.rs::a_projection_names_exactly_the_members_it_carries", "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], "elapsed_seconds": 1.8 @@ -77,7 +78,7 @@ "catchers": [ "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 1.7 + "elapsed_seconds": 1.9 }, { "id": "M38", @@ -85,7 +86,7 @@ "catchers": [ "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 1.6 + "elapsed_seconds": 1.9 }, { "id": "M39", @@ -93,7 +94,7 @@ "catchers": [ "rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture" ], - "elapsed_seconds": 1.8 + "elapsed_seconds": 2.0 }, { "id": "M40", @@ -101,7 +102,7 @@ "catchers": [ "rust-repro/tests/repro.rs::verify_refuses_each_structural_violation" ], - "elapsed_seconds": 2.0 + "elapsed_seconds": 2.1 }, { "id": "M41", @@ -109,7 +110,7 @@ "catchers": [ "rust-repro/tests/repro.rs::verify_refuses_each_structural_violation" ], - "elapsed_seconds": 1.8 + "elapsed_seconds": 2.0 } ] } diff --git a/docs/generated/p022-cp4-census.md b/docs/generated/p022-cp4-census.md index d5180729..d9202cf0 100644 --- a/docs/generated/p022-cp4-census.md +++ b/docs/generated/p022-cp4-census.md @@ -1,24 +1,38 @@ - + -# P-022 checkpoint 4 — measured census +# P-022 #259 — the Layer 3 measured census -Computed by `tests/verdict_census.py` (the interpretation `tests/test_verdict_fixtures.py` verifies against the Python projection) over the frozen Layer 3 ledger; the Rust half is `rust/crates/own-bridge/tests/verdicts.rs`. +Computed by `tests/verdict_census.py` and `tests/verdict_render_census.py` (the interpretations the two fixture harnesses verify against the Python projections) over the frozen ledgers; the Rust halves are `rust/crates/own-bridge/tests/verdicts.rs` and `.../tests/renders.rs`. + +**The surface this describes is checkpoint 5's**: the verdict replay compares EVERY `Finding` member (`message`, `related` and `flow` included) and every refusal in full, and the rendered-surface replay compares bytes. At checkpoint 4 the same ledger was compared on identity, anchor, kind and tiering only, and refusals up to their `message=` member; the counts below are the ledger's either way, which is why one fragment serves both and says which surface it means. | measure | value | |--------------------------------------------------------------------------------------|------:| -| goldens — Python's complete truth, one per planned case | 77 | +| goldens — Python's complete truth, one per planned case | 87 | | … swept from `tests/fixtures/ownir` | 22 | | … swept from `tests/fixtures/lowered` | 27 | | … swept from `tests/fixtures/summaries` | 9 | -| … synthetic controls (`manifest.json` cases) | 19 | +| … synthetic controls (`manifest.json` cases) | 29 | | reference refusals over all goldens | 5 | -| reference findings over all goldens | 134 | +| reference findings over all goldens | 159 | | declared Rust exclusions — the executable ledger `rust_replay_excluded` | 8 | | … refused by `check_facts` with an error containing `obligation protocol` | 2 | | … refused by `check_facts` with an error containing `outside the core's line domain` | 4 | | … refused at the typed `OwnIr` door (#294 OD-1) | 2 | -| replayed by Rust at the cp4 surface (goldens minus exclusions) | 69 | -| … reference refusals among them (compared by refusal class) | 5 | -| … findings among them (compared on the cp4 members) | 127 | +| replayed by Rust (goldens minus exclusions) | 79 | +| … reference refusals among them (compared in full) | 5 | +| … findings among them (compared on every `Finding` member) | 152 | + +The differential counts over the replayed set — Python-only, Rust-only, changed, ordering-only, unexplained — are asserted, not measured here: the Rust replay compares every replayed case's full ordered verdict list (or its refusal text) against the golden on every member, collects every divergence without fail-fast, and fails if one exists. A green `cargo test -p own-bridge --test verdicts` is 0 / 0 / 0 / 0 / 0 by construction; a non-zero count is a red build. + +## The rendered surfaces (BR-V9) + +A second family, and a different kind of comparison: its replay compares the **bytes**, because SARIF key order is part of this surface. Cases are listed, never swept — one exists to exercise a BR-V9 rule, and which rows each pins is the join the [surface inventory](p022-cp5-inventory.md) reports on. -The differential counts over the replayed set — Python-only, Rust-only, changed, ordering-only, unexplained — are asserted, not measured here: the Rust replay compares every replayed case's full ordered verdict list (or its refusal class) against the golden on the cp4 members, collects every divergence without fail-fast, and fails if one exists. A green `cargo test -p own-bridge --test verdicts` is 0 / 0 / 0 / 0 / 0 by construction; a non-zero count is a red build. +| measure | value | +|-----------------------------------------------------------------------|------:| +| cases — one per BR-V9 rule group, listed exhaustively in the manifest | 8 | +| … whose golden is a bridge refusal (nothing to render) | 1 | +| rendered lines compared byte-for-byte (4 formats, 2 host severities) | 104 | +| SARIF results compared byte-for-byte (both host severities) | 26 | +| BR-V9 ledger rows pinned by at least one case | 31 | diff --git a/docs/generated/p022-cp4-mutations.md b/docs/generated/p022-cp4-mutations.md index 178f805d..b512f396 100644 --- a/docs/generated/p022-cp4-mutations.md +++ b/docs/generated/p022-cp4-mutations.md @@ -4,12 +4,12 @@ Campaign `p022-cp4` — #259 cp4 analysis wiring: the BR-V1..V8 verdict mapping in own-bridge (skip list, map-or-raise subject, anchors, tiering, dedup key member by member, stable sort), the BR-D2/L8/M1 side paths, the two declared boundaries (protocol refusal, u32 coordinate refusal) and the own-analysis subject stamping the mapping rests on. -Definition: `docs/evidence/p022-cp4-mutations.json` (sha256 `6f858c6bf6a24cc0…`, 30 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp4-mutations.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. +Definition: `docs/evidence/p022-cp4-mutations.json` (sha256 `15b10f36c0d2b46b…`, 30 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp4-mutations.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. | measure | value | |------------------------------------------------------------|---| -| recorded at commit | `a529193e3acaeab435c435aff8606707cee1dad2` | -| packages tested (every workspace member, `--no-fail-fast`) | `own-analysis`, `own-bridge`, `own-cfg`, `own-diagnostics`, `own-ir`, `own-lowered`, `own-syntax` | +| recorded at commit | `5553dae32ddf5d22592e11e84c8ed10e8c2fbc3e` | +| packages tested (every workspace member, `--no-fail-fast`) | `own-analysis`, `own-bridge`, `own-cfg`, `own-diagnostics`, `own-ir`, `own-lowered`, `own-shadow`, `own-syntax` | | mutations | 30 | | caught | 30 | | survived | 0 | @@ -26,28 +26,28 @@ Definition: `docs/evidence/p022-cp4-mutations.json` (sha256 `6f858c6bf6a24cc0… | M03 | BR-V5 | BR-V5 OWN025 anchored at the acquire instead of the view site | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | | M04 | BR-V7 | BR-V7 dedup key drops column | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | | M05 | BR-V8 | BR-V8 sort key drops column | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M06 | BR-V7 | BR-V7 dedup key drops event | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M06 | BR-V7 | BR-V7 dedup key drops event (unobservable end to end since cp5.1 put `message` in the key) | caught | `own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member` | | M07 | — | DI004/DI005 duplicate site: last-wins becomes first-wins (own-analysis) | caught | `own-analysis/tests/fact_parity.rs::di_fact_parity`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M08 | BR-V6 | BR-V6 DI001 graded warning like DI002-005 | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M08 | BR-V6 | BR-V6 DI001 graded warning like DI002-005 | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | | M09 | BR-D2 | BR-D2 effect deps coerced instead of skipped (raw-document path) | caught | `own-bridge/src/lib.rs::verdict::tests::malformed_effect_entries_are_skipped_not_coerced` | | M10 | BR-L8 | BR-L8 OWN051 owned-local gate dropped | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M11 | BR-M1 | BR-M1 OWN052 note never minted | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M12 | BR-V3 | BR-V3 OWN001 leak emitted without a subject (own-analysis) | caught | `own-analysis/tests/subject.rs::leak_carries_the_acquire_origin`
`own-analysis/tests/subject.rs::origin_is_inherited_across_a_move`
`own-analysis/tests/subject.rs::param_leak_carries_the_param_origin`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M11 | BR-M1 | BR-M1 OWN052 note never minted | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M12 | BR-V3 | BR-V3 OWN001 leak emitted without a subject (own-analysis) | caught | `own-analysis/tests/subject.rs::leak_carries_the_acquire_origin`
`own-analysis/tests/subject.rs::origin_is_inherited_across_a_move`
`own-analysis/tests/subject.rs::param_leak_carries_the_param_origin`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | | M13 | BR-V3 | BR-V3 OWN014 emitted without a subject (own-analysis) | caught | `own-analysis/tests/subject.rs::region_escape_carries_the_source_identity`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M14 | — | protocol boundary: a protocol-bearing document is no longer refused | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M14 | — | protocol boundary: a protocol-bearing document is no longer refused | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | | M15 | — | coordinate boundary: core_line clamps instead of refusing | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M16 | BR-V6 | BR-V6 source tiering inverted (static warns, injected does not) | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M16 | BR-V6 | BR-V6 source tiering inverted (static warns, injected does not) | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | | M17 | BR-V6 | BR-V6 an empty ignore_reason suppresses | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M18 | BR-V7 | BR-V7 dedup removed | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M18 | BR-V7 | BR-V7 dedup removed | caught | `own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | | M19 | BR-V1 | BR-V1 ERROR-only half of the mapping predicate removed | caught | `own-bridge/src/lib.rs::verdict::tests::only_error_severity_core_verdicts_are_mapped` | | M20 | — | _as_col accepts 0 as a coordinate | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | | M21 | — | guarded DI site line: a negative site line becomes 1 instead of 0 | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | | M22 | — | OWN050 advisory never minted | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M23 | BR-V3 | BR-V3 handle recovered from the wrong subject separator | caught | `own-bridge/src/lib.rs::verdict::tests::only_error_severity_core_verdicts_are_mapped`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M23 | BR-V3 | BR-V3 handle recovered from the wrong subject separator | caught | `own-bridge/src/lib.rs::verdict::tests::a_flow_local_code_without_a_wording_keeps_the_core_message`
`own-bridge/src/lib.rs::verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two`
`own-bridge/src/lib.rs::verdict::tests::only_error_severity_core_verdicts_are_mapped`
`own-bridge/src/lib.rs::verdict::tests::the_capture_route_names_a_non_static_source_it_can_never_be_handed`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | | M24 | — | OWN051 line taken from the callee record instead of the call | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | | M25 | BR-V7 | BR-V7 dedup key drops handler | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | | M26 | BR-V7 | BR-V7 dedup key drops component | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M27 | BR-V7 | BR-V7 dedup key drops kind | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M28 | BR-V7 | BR-V7 dedup key drops severity | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M27 | BR-V7 | BR-V7 dedup key drops kind (unobservable end to end since cp5.1 put `message` in the key) | caught | `own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member` | +| M28 | BR-V7 | BR-V7 dedup key drops severity (unobservable end to end since cp5.1 put `message` in the key) | caught | `own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member` | | M29 | BR-V7 | BR-V7 dedup key drops ignore_reason | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | -| M30 | BR-V5 | BR-V5 DI/effect finding line taken from the registration instead of the finder's anchor | caught | `own-bridge/src/lib.rs::verdict::tests::di_coercions_match_the_reference`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M30 | BR-V5 | BR-V5 DI/effect finding line taken from the registration instead of the finder's anchor | caught | `own-bridge/src/lib.rs::verdict::tests::di_coercions_match_the_reference`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | diff --git a/docs/generated/p022-cp5-inventory.md b/docs/generated/p022-cp5-inventory.md new file mode 100644 index 00000000..a427832d --- /dev/null +++ b/docs/generated/p022-cp5-inventory.md @@ -0,0 +1,145 @@ + + +# P-022 checkpoint 5 — surface inventory (what the frozen goldens reach) + +Checkpoint 4 proved identity, anchor, kind and tiering over the replayed set ([census](p022-cp4-census.md)). Checkpoint 5 proves the three surfaces cp4 carried without comparing: the **messages** (BR-V4), the **evidence slices** (BR-V5) and the **rendered surfaces** (BR-V9). This fragment is the completeness ledger for those three: every branch read off `ownlang/ownir.py`, matched against the committed goldens. + +`all goldens` counts Python's complete truth; `replayed` counts only the cases the Rust replay runs (the ledger's `rust_replay_excluded` entries removed). A row whose **replayed** count is zero is a branch the golden corpus does not prove; each such row carries its **disposition** — what pins the branch instead, and why no facts document can reach it. A zero row with no disposition reads `GAP: no control`, which is a missing control, not a passing one. + +## BR-V4 — message synthesis, by who owns the string + +`bridge` — synthesized by `check_facts` from the handle record; `core-analysis` — the `message` property of `ownlang/di.py` / `ownlang/effects.py`'s own finding; `core-diagnostic` — the core `Diagnostic.message`, interpolated verbatim; `bridge-protocol` — the OBL family, which is #259 row 4b and outside cp5. + +| ledger row | surface | what it is | all goldens | replayed | +|---|---|---|---:|---:| +| `flowlocal_own001_never` | bridge | flow-local OWN001, never released | 27 | 25 | +| `flowlocal_own001_partial` | bridge | flow-local OWN001, released on some path | 10 | 10 | +| `flowlocal_own001_pool_partial` | bridge | flow-local OWN001 on a pooled buffer, returned on some path | 2 | 2 | +| `flowlocal_own002` | bridge | flow-local use-after-dispose | 6 | 6 | +| `flowlocal_own003` | bridge | flow-local double dispose | 1 | 1 | +| `flowlocal_own009` | bridge | flow-local maybe-use-after-dispose | 1 | 1 | +| `flowlocal_own002_pool` | bridge | flow-local use-after-return | 1 | 1 | +| `flowlocal_own003_pool` | bridge | flow-local double return | 1 | 1 | +| `flowlocal_own009_pool` | bridge | flow-local maybe-use-after-return | 1 | 1 | +| `flowlocal_fallback` | core-diagnostic | flow-local fallback: the core message, verbatim — **not replayed**: unreachable from any facts document (the nine-op flow vocabulary raises only codes that HAVE a wording); pinned by a `verdict::tests` control driven through `map_core`, whose expected text is read from the recorded probe `tests/fixtures/unreachable_branches.json`. The core message it interpolates is checkpoint 5.2's | 0 | 0 | +| `flowlocal_fallback_pool` | core-diagnostic | flow-local pooled fallback: the core message, verbatim — **not replayed**: unreachable from any facts document (the nine-op flow vocabulary raises only codes that HAVE a wording); pinned by a `verdict::tests` control driven through `map_core`, whose expected text is read from the recorded probe `tests/fixtures/unreachable_branches.json`. The core message it interpolates is checkpoint 5.2's | 0 | 0 | +| `own025_view` | bridge | OWN025 pooled-view wording | 2 | 2 | +| `own014_di_singleton` | bridge | OWN014 captive, source registered singleton | 4 | 4 | +| `own014_di_scoped` | bridge | OWN014 captive, source registered scoped | 1 | 1 | +| `own014_di_transient` | bridge | OWN014 captive, source registered transient — **not replayed**: unreachable from any facts document: `transient` is the shortest region, so no subscriber it could outlive exists, and an unrecognised lifetime never reaches `di_source_life` (the DI life map admits only the three). Pinned by `verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two`, whose expected text is not written in the test: it is read from the recorded probe `tests/fixtures/unreachable_branches.json` | 0 | 0 | +| `own014_di_unknown_life` | bridge | OWN014 captive, source lifetime outside the three known ones — **not replayed**: unreachable from any facts document: `transient` is the shortest region, so no subscriber it could outlive exists, and an unrecognised lifetime never reaches `di_source_life` (the DI life map admits only the three). Pinned by `verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two`, whose expected text is not written in the test: it is read from the recorded probe `tests/fixtures/unreachable_branches.json` | 0 | 0 | +| `own014_di_lambda` | bridge | OWN014 captive on an inline lambda handler (the no-'-=' note) | 1 | 1 | +| `own014_capture_static` | bridge | OWN014 capture of a static source | 5 | 5 | +| `own014_capture_named` | bridge | OWN014 capture of a named longer-lived source — **not replayed**: unreachable from any facts document: routing R3 mints a handle only for a source with a declared capture region, and `static` is the only entry in that table. Pinned by the `verdict::tests` control named for it, whose expected text is not written in the test: it is read from the recorded probe `tests/fixtures/unreachable_branches.json` | 0 | 0 | +| `own014_capture_lambda` | bridge | OWN014 capture on an inline lambda handler (the no-'-=' note) | 1 | 1 | +| `token_timer` | bridge | timer wording | 4 | 4 | +| `token_disposable_typed` | bridge | disposable field, `type` present | 8 | 8 | +| `token_disposable_untyped` | bridge | disposable field, no `type` | 2 | 2 | +| `token_local_disposable_typed` | bridge | local disposable, `type` present | 1 | 1 | +| `token_local_disposable_untyped` | bridge | local disposable, no `type` | 1 | 1 | +| `token_subscribe_injected` | bridge | ignored Subscribe() result, injected source | 2 | 2 | +| `token_subscribe_other` | bridge | ignored Subscribe() result, any other source | 2 | 2 | +| `pooled_never_returned` | bridge | pooled buffer never returned — the `pool` token wording AND the flow-local never-returned wording, one sentence (see `Branch`) | 4 | 4 | +| `token_subscription_injected` | bridge | plain `+=` subscription, injected source | 8 | 8 | +| `token_subscription_injected_lambda` | bridge | plain `+=` subscription, injected source, inline lambda | 1 | 1 | +| `token_subscription_other` | bridge | plain `+=` subscription, any other source | 17 | 17 | +| `token_subscription_other_lambda` | bridge | plain `+=` subscription, any other source, inline lambda | 1 | 1 | +| `advisory_own050` | bridge | OWN050 unresolved-reference note | 3 | 3 | +| `advisory_own051` | bridge | OWN051 unverified-transfer note | 6 | 6 | +| `advisory_own052` | bridge | OWN052 degraded-inference note | 1 | 1 | +| `di001_message` | core-analysis | DI001 captive message (di.py) | 11 | 9 | +| `di002_message` | core-analysis | DI002 weak-captive message (di.py) | 2 | 2 | +| `di003_message` | core-analysis | DI003 captured-transient message (di.py) | 1 | 1 | +| `di004_message` | core-analysis | DI004 root-resolution message (di.py) | 7 | 7 | +| `di005_message` | core-analysis | DI005 scope-cache message (di.py) | 5 | 5 | +| `eff001_message` | core-analysis | EFF001 storm message (effects.py) | 7 | 5 | +| `obl_message` | bridge-protocol | OBL001-005 message (4b, not cp5) — **not replayed**: #259 row 4b (the OBL analysis is not ported); outside cp5 by declaration | 1 | 0 | + +### Wording tails + +Each is its own degradation rule inside an analysis message — the tail is dropped, not blanked, when its location is unknown. + +| ledger row | surface | what it is | all goldens | replayed | +|---|---|---|---:|---:| +| `tail_consumed_typed` | wording tail | ` [consumed by the '' constructor at f:l]` | 4 | 4 | +| `tail_consumed_bare` | wording tail | ` [consumed by the constructor at f:l]` (impl type unknown) | 2 | 2 | +| `tail_consumed_absent` | wording tail | no consuming-constructor tail (location unknown) | 8 | 6 | +| `tail_registered` | wording tail | ` [singleton registered at f:l]` on DI004/DI005 | 7 | 7 | +| `tail_registered_absent` | wording tail | no registration tail (the primary IS the registration) | 5 | 5 | + +## BR-V5 — evidence slices + +One row per `related`/`flow` family; a slice matching no family (or two) fails the gate. + +| ledger row | surface | what it is | all goldens | replayed | +|---|---|---|---:|---:| +| `di_path_1` | flow | DI retention path, one resolvable hop (the rest dropped) | 2 | 1 | +| `di_path_2` | flow | DI retention path, captor → captured | 19 | 18 | +| `di_path_3plus` | flow | DI retention path with `via` hops | 4 | 4 | +| `di_consumer_related_typed` | related | DI consuming constructor, impl type known | 4 | 4 | +| `di_consumer_related_bare` | related | DI consuming constructor, impl type unknown | 2 | 2 | +| `di004_registration_related` | related | DI004 registration beside the call site | 3 | 3 | +| `di005_registration_related` | related | DI005 registration beside the store site | 4 | 4 | +| `capture_escape_flow` | flow | OWN014 subscribe site → source registration site | 4 | 4 | +| `effect_flow` | flow | EFF001 re-run site → identity-mint site | 4 | 3 | +| `flowlocal_flow_own002` | flow | flow-local OWN002 origin → violation | 4 | 4 | +| `flowlocal_flow_own003` | flow | flow-local OWN003 origin → violation | 1 | 1 | +| `flowlocal_flow_own009` | flow | flow-local OWN009 origin → violation | 1 | 1 | +| `flowlocal_flow_own002_pool` | flow | flow-local OWN002 origin → violation (pooled) | 1 | 1 | +| `flowlocal_flow_own003_pool` | flow | flow-local OWN003 origin → violation (pooled) | 1 | 1 | +| `flowlocal_flow_own009_pool` | flow | flow-local OWN009 origin → violation (pooled) | 1 | 1 | +| `flowlocal_flow_own025_pool` | flow | flow-local OWN025 origin → violation (pooled) | 2 | 2 | +| `protocol_flow` | flow | OBL opened → barrier (→ late close) — 4b, not cp5 — **not replayed**: #259 row 4b (the OBL analysis is not ported); outside cp5 by declaration | 0 | 0 | +| `protocol_flow_3` | flow | OBL opened → barrier → late close — 4b, not cp5 — **not replayed**: #259 row 4b (the OBL analysis is not ported); outside cp5 by declaration | 1 | 0 | + +### Degradations + +The rules that produce an EMPTY slice: a step whose line is unknown is omitted, and a slice left shorter than two steps is dropped. Counted separately, because a rule only ever seen firing positively has no negative control. + +| ledger row | surface | what it is | all goldens | replayed | +|---|---|---|---:|---:| +| `di_consumer_related_dropped` | related | DI001/2/3 with no consuming-constructor related (line < 1) | 8 | 6 | +| `di004_related_dropped` | related | DI004 with no registration related (the primary IS the registration) | 4 | 4 | +| `di005_related_dropped` | related | DI005 with no registration related (the primary IS the registration) | 1 | 1 | +| `capture_escape_flow_dropped` | flow | DI-sourced OWN014 with no escape slice (source registration unknown → < 2 steps) | 2 | 2 | +| `capture_flow_absent` | flow | OWN014 from the capture route: no escape slice by design (only the DI-sourced branch builds one) | 6 | 6 | +| `effect_flow_dropped` | flow | EFF001 with no slice (a re-run or mint line < 1) | 3 | 2 | +| `flowlocal_flow_absent` | flow | OWN001 on a local/pooled record: a single-point finding, no slice by design | 45 | 43 | + +## BR-V9 — rendered surfaces + +Coverage is matched out of the `tests/fixtures/verdict_renders/` family's `pins` ledger. + +| ledger row | surface | what it is | all goldens | replayed | +|---|---|---|---:|---:| +| `human_line` | render | `file:line: sev: [code] msg [resource: kind]` | 1 | 1 | +| `human_severity` | render | host severity in the human line | 1 | 1 | +| `github_line` | github | `::sev file=…,line=…,title=CODE::msg` | 1 | 1 | +| `github_severity` | github | host severity as the annotation level | 1 | 1 | +| `github_esc_percent` | github | `%` → `%25` in the message data | 1 | 1 | +| `github_esc_cr` | github | CR → `%0D` in the message data | 1 | 1 | +| `github_esc_lf` | github | LF → `%0A` in the message data | 1 | 1 | +| `github_esc_prop_colon` | github | `:` → `%3A` in a property value | 1 | 1 | +| `github_esc_prop_comma` | github | `,` → `%2C` in a property value | 1 | 1 | +| `msbuild_line` | msbuild | `file(line): sev CODE: msg [resource: kind]` | 1 | 1 | +| `msbuild_severity` | msbuild | host severity in the msbuild line | 1 | 1 | +| `fallback_human` | render | an unknown format falls back to the human line | 1 | 1 | +| `sarif_envelope` | sarif | `$schema` + `version` + one `run` | 1 | 1 | +| `sarif_driver` | sarif | `tool.driver.name` = Owen + `informationUri` | 1 | 1 | +| `sarif_rules` | sarif | rule catalogue: sorted, deduplicated + `TITLES` | 2 | 2 | +| `sarif_result_order` | sarif | results keep the bridge's own order (BR-V8), never the catalogue's | 1 | 1 | +| `sarif_schema_version` | sarif | the `ownirSchemaVersion` driver property | 1 | 1 | +| `sarif_level_note` | sarif | an advisory renders as `note` | 1 | 1 | +| `sarif_level_warning` | sarif | an intrinsic warning renders as `warning` | 1 | 1 | +| `sarif_level_error` | sarif | a provable leak renders as `error` | 1 | 1 | +| `sarif_level_host_warning` | sarif | `severity=warning` downgrades an error, never an advisory | 1 | 1 | +| `sarif_region` | sarif | `region.startLine` for a line ≥ 1 | 1 | 1 | +| `sarif_region_omitted` | sarif | `region` omitted entirely for line < 1 | 1 | 1 | +| `sarif_start_column` | sarif | `region.startColumn` only beside a line | 1 | 1 | +| `sarif_uri_backslash` | sarif | backslashes normalised in the artifact URI | 1 | 1 | +| `sarif_properties` | sarif | `resourceKind` always; component/event/handler only when non-empty | 1 | 1 | +| `sarif_related` | sarif | `relatedLocations` from `related` | 1 | 1 | +| `sarif_code_flows` | sarif | `codeFlows` from the ordered `flow` | 1 | 1 | +| `sarif_suppressions` | sarif | `suppressions` (`inSource` + justification) for a suppressed finding | 1 | 1 | +| `sarif_empty` | sarif | an empty finding list is a valid, empty run | 1 | 1 | +| `refusal_error` | surface | a bridge refusal projects as `{"error": …}` | 1 | 1 | diff --git a/docs/generated/p022-cp5-mutations.md b/docs/generated/p022-cp5-mutations.md new file mode 100644 index 00000000..233b71b9 --- /dev/null +++ b/docs/generated/p022-cp5-mutations.md @@ -0,0 +1,152 @@ + + +# P-022 checkpoint 5 — mutation campaigns + +One campaign per sub-checkpoint, each frozen at what it measured. Every mutation edits a **production** surface (P-022 discipline 2) and every workspace member runs for every mutation (discipline 3: no fail-fast); the counts are derived from the recorded runs by `scripts/mutate_campaign.summarize()`, never typed. + +## checkpoint 5.1 — the message matrix and the evidence slices + +Campaign `p022-cp5-1` — #259 cp5.1 messages and evidence: the BR-V4 wording matrix on the bridge (every flow-local split, the OWN025 view sentence, both OWN014 families with their lifetime phrases and the inline-lambda note, every token kind, the three advisories), the analysis-owned DI and effect messages, the BR-V5 evidence slices family by family with their line-guard and short-slice degradations, and the BR-V7 dedup key's new message member. Two mutations attack the CONTROLS rather than the port: they corrupt the recorded oracle wordings in tests/fixtures/unreachable_branches.json and require the Rust controls for the unreachable branches to fail, because a control that survives a drifting oracle record is carrying a literal and only appears to be reading the reference. + +Definition: `docs/evidence/p022-cp5-1.json` (sha256 `2b4651e54f2862f5…`, 42 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp5-1.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. + +| measure | value | +|------------------------------------------------------------|---| +| recorded at commit | `cdff105ca43ec5b1ced2bd25a8216f4f7b65deb3` | +| packages tested (every workspace member, `--no-fail-fast`) | `own-analysis`, `own-bridge`, `own-cfg`, `own-diagnostics`, `own-ir`, `own-lowered`, `own-shadow`, `own-syntax` | +| mutations | 42 | +| caught | 42 | +| 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 | BR-V4 | BR-V4 ever_released split inverted on the plain OWN001 wording | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M02 | BR-V4 | BR-V4 pool split ignored: a pooled OWN001 words as a plain local | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M03 | BR-V4 | BR-V4 pooled use-after-return wording says 'disposed' | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M04 | BR-V4 | BR-V4 pooled maybe-use wording collapses onto the plain one | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M05 | BR-V4 | BR-V4 OWN025 view wording drops the logical-length clause | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M06 | BR-V4 | BR-V4 fallback drops the core message it is there to carry | caught | `own-bridge/src/lib.rs::verdict::tests::a_flow_local_code_without_a_wording_keeps_the_core_message` | +| M07 | BR-V4 | BR-V4 every DI lifetime takes the singleton nice phrase | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M08 | BR-V4 | BR-V4 an unrecognised DI lifetime is hidden behind a generic phrase | caught | `own-bridge/src/lib.rs::verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two` | +| M09 | BR-V4 | BR-V4 lambda note appended whether or not the handler is a lambda | caught | `own-bridge/src/lib.rs::verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two`
`own-bridge/src/lib.rs::verdict::tests::the_capture_route_names_a_non_static_source_it_can_never_be_handed`
`own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M10 | BR-V4 | BR-V4 lambda note never appended | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M11 | BR-V4 | BR-V4 the capture route words every source as static | caught | `own-bridge/src/lib.rs::verdict::tests::the_capture_route_names_a_non_static_source_it_can_never_be_handed` | +| M12 | BR-V4 | BR-V4 timer wording drops the handler | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M13 | BR-V4 | BR-V4 disposable-field `type` parenthetical emitted on presence, not truthiness | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M14 | BR-V4 | BR-V4 local-disposable wording collapses onto the field wording | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M15 | BR-V4 | BR-V4 an injected Subscribe() result takes the provable-leak wording | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M16 | BR-V4 | BR-V4 pool token wording says 'disposed' | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M17 | BR-V4 | BR-V4 an injected subscription takes the provable-leak wording | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M18 | BR-V4 | BR-V4 OWN050 message drops the event it cannot verify | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M19 | BR-V4 | BR-V4 OWN051 message drops the inferred contract | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M20 | BR-V4 | BR-V4 OWN052 message drops the solve-failure reason | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M21 | BR-V4 | di.py chain separator changed (the retention path in the message) | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M22 | BR-V4 | di.py consumed tail names the service instead of dropping an unknown impl | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M23 | BR-V4 | di.py registration tail emitted even when the primary IS the registration | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M24 | BR-V4 | effects.py kind phrase collapses onto the object-literal one | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M25 | BR-V4 | effects.py `via` clause emitted for a single-element chain | caught | `own-analysis/src/lib.rs::effect::tests::the_via_clause_is_omitted_for_a_single_hop_chain` | +| M26 | BR-V5 | BR-V5 flow-local violation labels swapped between OWN002 and OWN003 | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M27 | BR-V5 | BR-V5 flow-local origin always words as an acquire, never a rent | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M28 | BR-V5 | BR-V5 flow-local slice keeps a step whose line is unknown | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M29 | BR-V5 | BR-V5 flow-local slice emitted when both sites coincide | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M30 | BR-V5 | BR-V5 DI path labels the last hop as a pass-through `via` | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M31 | BR-V5 | BR-V5 DI path emits a hop whose registration site is unknown | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M32 | BR-V5 | BR-V5 DI end label ignores the family (every path 'captures scoped') | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M33 | BR-V5 | BR-V5 DI consumer related emitted even with an unknown ctor line | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M34 | BR-V5 | BR-V5 DI consumer related names an unknown impl type | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M35 | BR-V5 | BR-V5 DI004/DI005 registration related emitted regardless of the site | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M36 | BR-V5 | BR-V5 OWN014 escape slice emitted with its source hop at an unknown line | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M37 | BR-V5 | BR-V5 OWN014 escape slice built from a subscribe site with no line | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M38 | BR-V5 | BR-V5 effect slice emitted when the mint line is unknown | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M39 | BR-V5 | BR-V5 effect slice steps reversed (mint before re-run) | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M40 | BR-V7 | BR-V7 dedup key drops the message member cp5 added | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M41 | BR-V4 | the recorded oracle wording drifts and the bridge control does not notice (i.e. it carries a literal, not the reference's answer) | caught | `own-bridge/src/lib.rs::verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two` | +| M42 | BR-V4 | the recorded oracle wording drifts and the effect control does not notice (the own-analysis half of the same claim) | caught | `own-analysis/src/lib.rs::effect::tests::the_via_clause_is_omitted_for_a_single_hop_chain` | + +## checkpoint 5.2 — the refusal text and the core message it quotes + +Campaign `p022-cp5-2` — #259 cp5.2 refusal text: the core message layer own-cfg now carries for the resolver, the seam own-analysis reads it through, and the CPython `repr` the BR-V3 map-or-raise text quotes it with — including the quote switch cp4's cut hid. Plus the shadow engine's verdict capture, which stopped being a partial projection in this checkpoint. + +Definition: `docs/evidence/p022-cp5-2.json` (sha256 `8641e3ff34598123…`, 10 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp5-2.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. + +| measure | value | +|------------------------------------------------------------|---| +| recorded at commit | `2a7ff6d4ab2877b930f79f09bc6f55bfbffd094e` | +| packages tested (every workspace member, `--no-fail-fast`) | `own-analysis`, `own-bridge`, `own-cfg`, `own-diagnostics`, `own-ir`, `own-lowered`, `own-shadow`, `own-syntax` | +| mutations | 10 | +| caught | 10 | +| 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 | BR-V3 | the resolver's OWN030 message drops the name it interpolates | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M02 | BR-V3 | the resolver emits a message-less diagnostic again (the title stands in) | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M03 | BR-V3 | own-analysis ignores the message own-cfg carries and substitutes the title | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M04 | BR-V3 | py_repr always single-quotes (cp4's placeholder, the bug cp5.2 found) | caught | `own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M05 | BR-V3 | py_repr's quote switch inverted (double unless a quote is present) | caught | `own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M06 | BR-V3 | py_repr leaves a backslash unescaped | caught | `own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch` | +| M07 | BR-V3 | py_repr leaves an ASCII control character raw | caught | `own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch` | +| M08 | BR-V3 | py_repr renders an absent subject as an empty string, not None | caught | `own-bridge/src/lib.rs::verdict::tests::py_repr_matches_cpython_including_the_quote_switch`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M09 | engine protocol | the shadow capture emits an empty evidence slice while declaring the layer full | caught | `own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M10 | engine protocol | the shadow capture drops a member while still declaring the layer full | caught | `own-shadow/tests/engine.rs::a_projection_names_exactly_the_members_it_carries`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | + +## checkpoint 5.3 — the rendered surfaces + +Campaign `p022-cp5-3` — #259 cp5.3 rendered surfaces (BR-V9): the three line-per-finding formats and their host-severity pass-through, every workflow-command escaping rule in both directions and in the order they compose, and the bridge's SARIF log — level mapping, sorted and deduplicated rule catalogue with titles, the ownirSchemaVersion stamp, region omission and startColumn, backslash normalisation, conditional properties, both evidence projections (including the one place reusing the core's builder would be a bug), suppressions, and the result order. + +Definition: `docs/evidence/p022-cp5-3.json` (sha256 `6b064c3d76eae07a…`, 29 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp5-3.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. + +| measure | value | +|------------------------------------------------------------|---| +| recorded at commit | `ceb8521557128bddca96ea6addb817231fde4f56` | +| packages tested (every workspace member, `--no-fail-fast`) | `own-analysis`, `own-bridge`, `own-cfg`, `own-diagnostics`, `own-ir`, `own-lowered`, `own-shadow`, `own-syntax` | +| mutations | 29 | +| caught | 29 | +| 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 | BR-V9 | the human line drops the [resource:] tag | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M02 | BR-V9 | the human line hard-codes error, ignoring the host severity | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M03 | BR-V9 | the github annotation swaps its file and line properties | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M04 | BR-V9 | the github annotation hard-codes the error level | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M05 | BR-V9 | workflow-command data leaves '%' unescaped | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M06 | BR-V9 | workflow-command data leaves CR unescaped | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M07 | BR-V9 | workflow-command data leaves LF unescaped | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M08 | BR-V9 | a workflow-command property value leaves ':' unescaped | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M09 | BR-V9 | a workflow-command property value leaves ',' unescaped | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M10 | BR-V9 | a property value escapes its separators BEFORE the data, double-escaping the % | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M11 | BR-V9 | the msbuild line uses the human separator instead of parentheses | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M12 | BR-V9 | the msbuild line hard-codes error, ignoring the host severity | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M13 | BR-V9 | an unknown format falls back to the github annotation, not the human line | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M14 | BR-V6 | an advisory renders as a warning instead of SARIF's note | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M15 | BR-V6 | the host severity no longer downgrades a provable leak | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M16 | BR-V6 | an intrinsic warning tier is ignored and renders as error | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M17 | BR-V9 | the SARIF rule catalogue is not sorted | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M18 | BR-V9 | the SARIF rule catalogue keeps duplicate codes | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M19 | BR-V9 | a rule's shortDescription is the code, never its title | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M20 | BR-V9 | the driver drops the ownirSchemaVersion stamp | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M21 | BR-V9 | a region is emitted for a file-level (line < 1) finding | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M22 | BR-V9 | startColumn is emitted as null when the producer reported none | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M23 | BR-V9 | the artifact URI keeps a Windows backslash | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M24 | BR-V9 | an empty component/event/handler is emitted as a property | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M25 | BR-V9 | relatedLocations adopts the core's empty-file filter, dropping a step the bridge emits | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M26 | BR-V9 | relatedLocations keeps a step whose line is unknown (defensive: no facts document can build such a step) | caught | `own-bridge/src/lib.rs::render::tests::a_related_step_with_no_line_is_dropped` | +| M27 | BR-V9 | codeFlows is never built from the ordered slice | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M28 | BR-V9 | a suppressed finding loses its suppressions array | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M29 | BR-V8 | SARIF results are sorted by code, destroying the bridge's own order | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | diff --git a/docs/generated/p022-shadow-census.md b/docs/generated/p022-shadow-census.md index 79ed3f4c..b89419ec 100644 --- a/docs/generated/p022-shadow-census.md +++ b/docs/generated/p022-shadow-census.md @@ -24,8 +24,8 @@ which also states the byte-level boundary repeated in the unmeasured set below. | `tests/fixtures/ownir` | 22 | | `tests/fixtures/repro` | 3 | | `tests/fixtures/summaries` | 9 | -| `tests/fixtures/verdicts` | 19 | -| **total** | **80** | +| `tests/fixtures/verdicts` | 29 | +| **total** | **90** | Every one of those documents is canonicalized and hashed by the reference (`ownlang/repro.py`) and re-hashed from the same file by the port @@ -36,8 +36,8 @@ the difference is named in the unmeasured set below. | surface | count | |---|---| -| documents captured and digest-pinned | 80 | -| tamper controls (one changed character per document, refusal required) | 80 | +| documents captured and digest-pinned | 90 | +| tamper controls (one changed character per document, refusal required) | 90 | | documents both engines must REFUSE to name (`domain_refusals`) | 6 | | reproduction artifacts committed and replayed byte-for-byte | 9 | | structural negative controls on `verify` (each side) | 18 | @@ -51,13 +51,22 @@ it could **produce**. Over the committed artifacts: | engine | layers produced | layers refused | projection `full` | projection `partial` | |---|---|---|---|---| | `python-ownlang` | 24 | 3 | 27 | 0 | -| `rust-own-bridge` | 20 | 7 | 19 | 8 | - -The port's `partial` layers are its verdict surface: `own_bridge::check_facts` -is at the #259 checkpoint-4 projection, which carries every `Finding` member -except `message`, `related` and `flow`. It says so in the artifact rather than -emitting a short document a later comparison would score as agreement, and a -test asserts the claim matches the records byte for byte. +| `rust-own-bridge` | 20 | 7 | 27 | 0 | + +The port's `partial` column read non-zero until #259 cp5.1/5.2: its verdict +surface sat at the checkpoint-4 projection, carrying every `Finding` member +except `message`, `related` and `flow`, and said so in the artifact rather than +emitting a short document a later comparison would score as agreement. Those +members are ported, so the layer is `full` and no partial projection remains — +a fact about this port's progress, not a reason to drop the field. The check +moved with it: it now asserts a `full` claim against the complete Layer 3 +record too, because a `full` declared over a short document is the over-claim +that became reachable the moment nothing was partial. + +**Still not shadow mode, and still not the verdict layer entering it.** The +reducer REFUSES the verdict layer and records the refusal in every reduction; +what changed above is one engine's declaration of what it puts in the +envelope. **Layer envelopes where the two engines' status differs** — structural accounting, not a content comparison, and every one of them a boundary the port @@ -129,7 +138,7 @@ non-zero counter there is not representable as a passing build. The gates: - `own-shadow/tests/repro.rs::the_canonical_form_ignores_only_insignificant_text_formatting` - `own-shadow/tests/repro.rs::values_outside_the_canonical_domain_are_refused_at_parse` - `own-shadow/tests/repro.rs::verify_refuses_each_structural_violation` -- `own-shadow/tests/engine.rs::a_partial_projection_names_exactly_the_members_it_carries` +- `own-shadow/tests/engine.rs::a_projection_names_exactly_the_members_it_carries` - `own-shadow/tests/engine.rs::both_engines_report_the_same_layers_in_the_same_order` - `own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` - `own-shadow/tests/trace.rs::a_mint_order_shift_moves_the_order_but_not_the_stable_ids` diff --git a/docs/generated/p022-shadow-mutations.md b/docs/generated/p022-shadow-mutations.md index 4692f51c..31f4e717 100644 --- a/docs/generated/p022-shadow-mutations.md +++ b/docs/generated/p022-shadow-mutations.md @@ -60,11 +60,11 @@ Definition: `docs/evidence/p022-shadow-cp1.json` (sha256 `a5df997a1d84effc…`, Campaign `p022-shadow-cp2` — The mutation campaign for P-022 step 7a checkpoint 2 (the engine protocol: how each engine reports its per-layer outputs, and what it declares it could produce). Separate from the cp1 campaign on purpose — each checkpoint's evidence stays frozen at what it measured, so a later checkpoint cannot quietly restate an earlier one's numbers. Every mutation edits a PRODUCTION surface — ownlang/repro.py or rust/crates/own-shadow/src/ — never a test. Three layers run for every mutation (discipline 3: no fail-fast), including the port's own engine suite. M00 is the harness-honesty control. -Definition: `docs/evidence/p022-shadow-cp2.json` (sha256 `fdeb494e11d5169f…`, 11 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-shadow-cp2.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. +Definition: `docs/evidence/p022-shadow-cp2.json` (sha256 `bd3fd24d65f53997…`, 11 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-shadow-cp2.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. | measure | value | |--------------------------------------------------|---| -| recorded at commit | `0cdbd0f4410bb5ee4a418337f567e515f4146b3b` | +| recorded at commit | `d726b06192c5fd76c94b02fab1011f55a453c14c` | | layers run (every one, for every mutation) | `python`, `rust-repro`, `rust-engine`, `rust-unit` | | mutations | 11 | | caught | 11 | @@ -81,8 +81,8 @@ Definition: `docs/evidence/p022-shadow-cp2.json` (sha256 `fdeb494e11d5169f…`, | M32 | engine protocol: an engine writes only its own entry, and carries foreign ones through | the reference silently drops the foreign engine entries when regenerating | caught | `python::artifact-golden` | | M33 | engine protocol: a partial projection must NAME the members it carries | the reference's verification accepts a partial projection that names no members | caught | `python::structural-control` | | M34 | engine protocol: a full projection emits the whole surface and claims nothing else | the reference's verification accepts a 'full' projection that also names members | caught | `python::structural-control` | -| M35 | engine protocol: a projection that over-claims is exactly what the field exists to prevent | the port declares its verdict layer FULL while it is at the checkpoint-4 projection | caught | `rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | -| M36 | engine protocol: a projection names exactly the members its documents carry | the port drops `column` from its verdict records while still claiming it | caught | `rust-engine/tests/engine.rs::a_partial_projection_names_exactly_the_members_it_carries`
`rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M35 | engine protocol: a projection that over-claims is exactly what the field exists to prevent | the port declares its verdict layer FULL while emitting a short record (the over-claim that became reachable when the layer stopped being partial, #259 cp5.1/5.2) | caught | `rust-engine/tests/engine.rs::a_projection_names_exactly_the_members_it_carries`
`rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M36 | engine protocol: a projection names exactly the members its documents carry | the port drops `column` from its verdict records while still claiming it | caught | `rust-engine/tests/engine.rs::a_projection_names_exactly_the_members_it_carries`
`rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | | M37 | artifact: status is produced or refused, and a refusal carries no document | the port stamps a refused layer with the 'produced' status | caught | `rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | | M38 | artifact: surface_version is null when the surface has none — absence is data | the port claims a surface version for the MOS dump, which has none | caught | `rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | | M39 | artifact: every engine reports exactly the frozen layers, in that order | the port reports a typed-door refusal on one layer instead of all three | caught | `rust-engine/tests/engine.rs::this_engine_reproduces_its_committed_capture` | diff --git a/docs/notes/p022-bridge-verdict-checkpoint4.md b/docs/notes/p022-bridge-verdict-checkpoint4.md index da95e6ac..07d100b3 100644 --- a/docs/notes/p022-bridge-verdict-checkpoint4.md +++ b/docs/notes/p022-bridge-verdict-checkpoint4.md @@ -71,6 +71,11 @@ handler, kind, advisory, severity, ignore_reason`. Not compared yet: `message`, `related`, `flow` — the goldens carry them, cp5 compares them without regenerating a golden. +> Read as history. **#259 cp5 has since landed** and the replay compares every +> member, plus every refusal in full; the census fragment linked above +> describes that surface, not this one, and says so in its first paragraph. +> See [the cp5 note](p022-bridge-verdict-checkpoint5.md). + ### The unmeasured set is named, not hidden Each exclusion is an entry in `rust_replay_excluded` with a reason and an diff --git a/docs/notes/p022-bridge-verdict-checkpoint5.md b/docs/notes/p022-bridge-verdict-checkpoint5.md new file mode 100644 index 00000000..a37cc2f7 --- /dev/null +++ b/docs/notes/p022-bridge-verdict-checkpoint5.md @@ -0,0 +1,442 @@ +# P-022 step 6b (#259) — checkpoint 5: messages, evidence, rendered surfaces + +> Status: **checkpoint 5 complete at its surface** — 5.0 inventory, 5.1 +> messages and evidence, 5.2 refusal text, 5.3 rendered surfaces, 5.4 the +> status surfaces and the census. This note names what checkpoint 5 has to prove, who owns each +> string it must reproduce, and what each sub-checkpoint closed. Every count +> lives in the generated fragments +> [`p022-cp5-inventory.md`](../generated/p022-cp5-inventory.md) and +> [`p022-cp5-mutations.md`](../generated/p022-cp5-mutations.md); nothing is +> typed here. + +Checkpoint 4 ([note](p022-bridge-verdict-checkpoint4.md)) proved identity, +anchor, kind and tiering over the replayed set, and left three members of +`ownir.Finding` carried by the goldens but not compared: `message`, `related` +and `flow` — plus the rendered surfaces (`render_finding`, `build_sarif`), +which have no golden at all. Checkpoint 5 turns each of those into a proof +against **the same frozen goldens**: no golden in +`tests/fixtures/{verdicts,lowered,summaries,ownir}` is regenerated or edited +beside the implementation, because "the port disagreed with the golden → +regenerate → agreement" is the one move this family exists to make impossible. + +## 1. Who owns each string + +A finding's `message` reaches the Layer 3 document from **three** places, and +the distinction decides where each is ported. The inventory fragment carries +one row per branch with its owner and its measured coverage; the shape is: + +| owner | what | where it lives (reference) | where it must live (port) | +|---|---|---|---| +| **bridge** | the BR-V4 matrix — every flow-local wording, the OWN025 view sentence, the OWN014 capture/captive sentences with their `nice` lifetime phrases and the inline-lambda note, the six token-kind sentences, and the OWN050/051/052 advisories | `ownlang/ownir.py::check_facts` (and `_unresolved_findings`, the OWN051 mint in `_lower_flow`) | `own-bridge` (`verdict.rs`) — cp5.1 | +| **core analysis** | DI001–DI005 and EFF001 — the bridge copies `c.message` / `s.message` from the finder's own value and never rewords it (BR-B1: the analysis owns its verdict) | `ownlang/di.py` (five `message` properties + `_consumed_suffix`), `ownlang/effects.py` (`EffectStorm.message`) | `own-analysis` (`di.rs`, `effect.rs`) — cp5.1 | +| **core diagnostic** | the two flow-local fallbacks (`IDisposable local 'x': `, and its pooled twin) and the `message=` member of the BR-V3 map-or-raise refusal text | the core `Diagnostic.message` built by `ownlang/cfg.py` / `ownlang/analysis.py` / `ownlang/lifetimes.py` | `own-cfg` / `own-analysis` — cp5.2 | +| **bridge, out of scope** | OBL001–005 (`_protocol_message`) | `ownlang/ownir.py::_protocol_findings` | #259 row **4b**, not cp5 — the port refuses a protocol-bearing document | + +### The core-diagnostic layer does not exist on the Rust side yet + +This is the checkpoint's one real gap, and it was checked rather than assumed: + +* every Rust core diagnostic is constructed with its **title** as the message — + `own-analysis/src/check.rs:29`, `lifetime.rs:25`, `lifetime.rs:161`, + `ownership.rs:232`, `di.rs:425`, `effect.rs:239` all read + `title(code).unwrap_or(code)`; +* `own-cfg`'s own diagnostic value carries **no message field at all** + (`own-cfg/src/lib.rs:50` — `Diag { code, line }`), so the resolver's + `undefined name 'loc_0'` has nowhere to live today; +* the `own-diagnostics` families that look like they cover this do not. + `tests/render_replay.rs` proves `Diagnostic::render` / `render_pretty` over a + message the **fixture supplies**; `tests/model_replay.rs` proves the value + shape; `own-analysis/tests/parity.rs` compares `(line, code)` only. So the + message *rendering* is proven and the message *text an analysis produces* is + not proven anywhere — exactly as cp4 recorded ("this core's messages are + still titles"). + +Consequence for cp5.2: the three `hoist_neg_*` refusals are compared up to +their `message=` member today only because `own_cfg::Diag` cannot carry the +name the reference interpolates. Removing that comparison boundary means +teaching the resolver's diagnostic to carry its message — additive data on a +core value, the same shape of change cp4 made for `subject`, and the same +question has to be answered again: does it reach any **serialized** core +surface? (§4 below.) + +### The analysis values are missing their presentation data + +`own-analysis::di::Service` deliberately dropped the ctor/site metadata +("presentation-only metadata … omitted — evidence and SARIF are a later step", +`di.rs:47`). cp5 is that later step, so cp5.1 restores: + +* `Service`: `ctor_file`, `ctor_line`, `ctor_type` — `_consumed_suffix`'s input + and the DI001/002/003 `related` anchor; +* `DiFinding`: its `message`, and the registration `(file, line)` the DI004 / + DI005 `related` needs *beside* the call/store-site primary; +* `EffectStorm`: its `message`, plus `origin_kind` and `decl_line` — the second + hop of the effect slice is the mint site, which the port does not carry. + +None of that changes a verdict: it is data the finders already computed and +threw away. + +## 2. The evidence slices (BR-V5) + +Every family, and every degradation rule, is one ledger row in the fragment: +the DI retention path through `di_path_steps` (one, two, and `via`-hop +lengths), the DI consuming-constructor `related` (with and without a known impl +type), the DI004 and DI005 registration `related`, the OWN014 escape slice +(subscribe site → source registration site), the EFF001 slice (re-run site → +identity-mint site), and the flow-local origin → violation slice per code and +per pool wording. The degradations are the rules that yield an **empty** slice: +a step whose line is `< 1` is omitted, a slice left shorter than two steps is +dropped, and — separately — the shapes that never carry a slice by design (a +flow-local OWN001 is a single point; an OWN014 from the capture route has no +registration hop to reach). + +One reading worth recording, because a naive port gets it backwards: the +"shorter than two steps is dropped" rule is **not** universal. It is applied by +the OWN014 escape slice (`len(steps) >= 2`), by `_flow_local_steps` (which +returns a pair or nothing) and by the effect slice (both lines `>= 1`); +`di_path_steps` has no such guard, so a DI path with exactly one resolvable +registration site emits a **one-step** `flow`. The goldens contain such a case. + +## 3. The rendered surfaces (BR-V9) + +`render_finding` (human / github / msbuild) and `build_sarif` on the bridge +path have **no golden of any kind** — Layer 3 froze the finding list, never its +renderings. What pins them today is `tests/test_ownir.py`, and reading it +against BR-V9 leaves named holes: the workflow-command escaping is pinned for +`%`, LF and the property separators `:`/`,` but **not** for CR; the SARIF +`ownirSchemaVersion` driver stamp is pinned nowhere in the suite; and the +bridge SARIF's `region` omission for `line < 1`, its `startColumn`, its +backslash-normalised URI, its `relatedLocations`/`codeFlows` projection and its +`suppressions` array are each pinned by at most one incidental assertion rather +than by a case that exists for them. + +So cp5.3 is a **new fixture family**, not a tightening: an observer emitter +beside `ownlang/verdicts.py`, its own frozen manifest ledger, a verify/`--write` +harness with red stale/missing/orphaned states, and a Rust replay with byte +equality and zero Python. Its ledger rows are already declared in the inventory +fragment and read zero until the family exists. + +Two constraints it inherits: + +* **Reuse where the format is the core's.** `own_diagnostics::sarif` already + ports `_phys`, `related_locations` and `code_flow`; the bridge's SARIF differs + from the core's (`properties`, `suppressions`, `startColumn`, the + `ownirSchemaVersion` stamp, a per-finding file rather than one run file), so + the shared parts are reused and the bridge-specific parts are built in + `own-bridge` — `own-diagnostics`' behaviour on the core path does not move. +* **`subject` must not leak.** cp4 established that no Rust output surface + serializes a diagnostic's `subject`, and promised cp5 would re-check it once + the bridge grew render and SARIF paths. `ownir.Finding` has no `subject` + member at all, so a bridge surface that emitted one would be inventing a + field; cp5.3 proves that with a test over the serialized surfaces rather than + by restating the promise. + +## 4. What cp5 must not do + +* **No golden is regenerated or edited.** A disagreement is classified — port + bug (fix the Rust), declared boundary (owner decision required, a stop), or a + Python bug (report, do not fix: Python is the oracle and the BR-V4 strings are + normative — OwnAudit fingerprints depend on them). +* **No `ownlang/` production change.** New observer modules only, exactly as + `verdicts.py` and `lowered.py` are. +* **`rust_replay_excluded` neither grows nor shrinks** without an owner + decision. Its families stay as cp4 left them: the protocol documents + (row 4b), the `u32` coordinate-domain controls (a contract decision the owner + has stated a direction for — Python-first tightening — but has not taken), and + the OD-1 typed-door controls (measured). +* **`own-shadow` does not grow a Layer 3 reducer.** The verdict layer stays + refused there until #260's acceptance, after 4b. + +## 5. The wording that will be true after cp5 + +> Layer 3 parity over the measured set at the full `Finding` and rendered +> surfaces; unmeasured set: protocol documents (4b), coordinate-domain controls +> (decision owed), OD-1 door controls. + +Not "verdict parity complete", not "#259 complete", not "shadow mode", not +"P-022 done". + +## 6. The named gaps, and how cp5 closes each + +Every row the inventory fragment marks **(gap)** is a branch the corpus cannot +prove. They fall into four groups, and none of them is closed by declaring a +survivor — a synthetic case is added under the manifest ledger instead (the +`M19` precedent from cp4 applies only where a production input genuinely cannot +reach the branch, and the test has to say so itself): + +1. **Flow-local wordings the corpus never mints** — the `OWN009` sentences, the + pooled `OWN002`/`OWN003`/`OWN009` sentences, and the two fallbacks that + interpolate a core message. New synthetic verdict cases (cp5.1); the + fallbacks additionally need cp5.2's core message layer to be meaningful. +2. **OWN014 wordings the corpus never mints** — the DI `scoped` / `transient` / + unknown-lifetime phrases, both inline-lambda notes, and the named + (non-static) capture origin. New synthetic verdict cases (cp5.1). +3. **Evidence families and degradations reachable only through an excluded + case** — the one-step DI path and the dropped effect slice live today only in + documents the Rust side refuses by declared boundary, so they are carried but + never replayed. New synthetic cases inside the replayed set (cp5.1). +4. **Every BR-V9 row** — no fixture family exists. cp5.3. + +The mutation campaign for each checkpoint is what makes the closure evidence +rather than intention: a mutation the corpus does not catch is a missing +control, and the answer is the synthetic case, never a declared survivor. + +## 7. What checkpoint 5.1 landed + +`own_bridge::Finding` grew `message`, `related` and `flow`, the BR-V4 matrix +and the BR-V5 slice builders were ported, and `own-bridge/tests/verdicts.rs` +now compares **every** member of the reference's `Finding` — cp4's identity, +anchor, kind and tiering plus the three it carried without comparing. **No +golden was regenerated or edited to reach that**: the goldens have carried +those three members since cp4, and the replay went green against the files as +committed. + +Three pieces of it are additive core data rather than bridge logic, because the +reference puts them there and BR-B1 says the analysis owns its verdict: + +* `own_analysis::di::Service` regained the ctor metadata #214 had dropped as + "presentation-only", and `DiFinding` now carries its `message`, the + registration `(file, line)` its DI004/DI005 `related` needs beside the + call/store-site primary, and the raw `site_line` that says whether the + primary came from a site at all — which `line` alone cannot, once the + registration fallback has been applied; +* `own_analysis::effect::EffectStorm` now carries its `message`, plus the + `origin_kind`, `decl_line` and reference `chain` it is built from — the + reference's `_Lattice` already computed the chain and the port was throwing + it away; +* nothing else moved. `check_di` and `effect_diagnostics` (the `(line, code)` + projections) are untouched, so no existing core surface changed shape. + +### Divergences found, and how each was classified + +**None.** The first full-equality run over the 69 cases cp4 replayed was green, +and the smoke check that the comparison bites was made explicitly (breaking one +wording turns fourteen cases red) rather than inferred from a passing suite. +That is a statement about the measured set only — see the next section for what +the measured set did not contain. + +### Synthetic cases, because a green replay over a corpus that never reaches a branch proves nothing about it + +The cp5.0 inventory named every wording and slice the goldens do not reach. +Each one that a facts document *can* reach is now a case under the frozen +manifest ledger — the OWN009 and pooled flow-local wordings with their slices, +the DI-scoped and both inline-lambda OWN014 wordings, the injected-source +lambda note, the dropped OWN014 escape slice, the bare consuming-constructor +tail, the one-step DI retention path, and the dropped effect slice. Adding them +rewrote **no** existing record in either the verdict ledger or the shadow +slice's digest ledger, which is P-022 discipline rule 4 measured rather than +asserted. + +The branches a facts document *cannot* reach are pinned instead by controls in +`verdict::tests`, driven through `map_core` — the production path — in the +shape cp4's `M19` established: the two flow-local fallbacks (the nine-op flow +vocabulary raises only codes that already have a wording), the `transient` and +unrecognised DI lifetime phrases (nothing is shorter than the transient region, +and the DI life map admits only the three lifetimes), and the capture route's +named-source origin (routing R3 mints a handle only for a source with a +declared capture region, and `static` is the only one). Their expected text is +**the reference's own output**, and it is not written in the Rust tests: it is +read from `tests/fixtures/unreachable_branches.json`, which +`tests/test_unreachable_branch_probe.py` produces by running `check_facts` with +its lowering and core substituted — the only way to ask the oracle about a +state its own inputs cannot construct. That makes "the reference says so" a +re-runnable fact rather than a claim about how carefully someone read +`ownir.py`, and it removes the second copy of the text a port could otherwise +drift into agreeing with instead of with Python. + +What the probe does **not** prove is worth stating in the same breath: the +substitution removes reachability, ordering and the pipeline around the branch, +so it can never stand in for a golden anywhere a golden is possible. It is used +only where one is not. + +Every remaining zero row in the inventory fragment now carries its disposition; +a zero row without one renders as `GAP: no control`, so the ledger cannot go +quiet about a branch nobody covered. + +### One property of the surface, recorded rather than papered over + +The pooled "rented but never returned" sentence is emitted by **two** branches +of the matrix — the flow-local never-returned wording and the `pool` token +wording — on the same `kind`, with the same empty `handler`. Nothing in a +serialized `Finding` separates them, so the inventory carries them as one row. +A port that reached the sentence by the other branch would produce a +byte-identical golden; that is what parity on this surface means, and it is +stated on the row instead of being hidden behind a discriminator that does not +exist. + +### The finding cp5.1 did not expect: `message` blinded three of BR-V7's controls + +Re-running the checkpoint-4 campaign against the cp5.1 tree turned five +`BR-V7 dedup key drops ` mutations from caught into survived. That is +not a weakening, and it is not noise: putting `message` in the key made three +of its members **unobservable at the output surface**. Every wording that +varies with `event`, `kind` or `severity` interpolates it, so downstream of +`check_facts` there is no document producing two findings equal on the message +and differing on one of the three — dropping such a member from the key can no +longer change any output. + +Two of the five were recoverable and are now goldens +(`verdict_dedup_key_members`): the disposable-field wording names no handler, +so two `Dispose*` methods on one field are separated by `handler` alone; the +flow-local wording names no component, so the same local leaking in two methods +of one file is separated by `component` alone. The other three drive `dedup` +directly — extracted from `check_facts` for exactly that reason, so the control +runs the production function rather than a copy of it — and the campaign +definition now names that control instead of the replay, because the replay +genuinely cannot catch them any more. + +Recording the shape of it, since it will recur: **a comparison surface that +gains a member can lose controls for the members it subsumes.** The campaign is +what surfaced it; a green suite would not have. + +## 8. What checkpoint 5.2 landed + +The `message=` cut is gone: the three `hoist_neg_*` refusals compare **byte for +byte**, with no normalization on either side. What it took: + +* **`own_cfg::Diag` carries a message.** The resolver's `undefined name ''` + is the one core text the bridge's map-or-raise refusal interpolates over the + measured corpus — measured, not assumed: driving every facts document in the + four shared corpora through `to_module` + `check_module` and filtering to the + diagnostics that fail to map yields exactly one message, and it is that one. +* **`own-analysis` reads it.** `push_cfg_diag` uses the carried message and + falls back to the code's title where `own-cfg` has none. +* **The remainder is a tripwire, not a blind spot.** `Diag::message` is an + `Option`, deliberately: a code whose text is not ported still renders as its + title, and because the refusal comparison is now byte-exact, the first golden + that refuses on such a code goes **red** demanding the message instead of + agreeing with a title. That is why porting only what the surface consumes is + safe here, and it is the reason the cut had to go before the port grew. + +### The bug the cut was hiding + +Removing the normalization turned the three refusals red immediately, on a +member cp4 could not see: **`py_repr`**. The reference formats the interpolated +message with CPython's `repr`, which switches from `'` to `"` when the string +contains a single quote and no double quote — and *every* core message that +names an identifier does. cp4's placeholder quoted unconditionally with `'`, so +it produced `message='undefined name 'loc_0''` where the reference produces +`message="undefined name 'loc_0'"`. + +Classified as a **port bug**, fixed in Rust, and pinned by a unit control whose +expected values are CPython's `repr()` output for the quote switch, both escape +directions, the backslash, the ASCII control range and the `None` case. This is +the whole argument for removing a comparison boundary rather than living with +it: the boundary was not hiding "text we have not ported", it was hiding a +formatting defect in code that *was* ported. + +### The consequence in `own-shadow`, and what it is not + +The port's shadow capture declared its verdict layer a **partial** projection +whose stated reason was that BR-V4 and the evidence slices "are checkpoint 5 +and are not ported". After 5.1 that sentence was false, and a committed +artifact carrying a false declaration is worse than none. So the layer now +emits every `Finding` member and declares `full`, and the artifacts and traces +were regenerated. + +**This is not the verdict layer entering shadow mode.** The reducer still +refuses it and records the refusal in every reduction; that stays until #260's +acceptance, after row 4b. What changed is one engine's declaration of what it +puts in the envelope — which is precisely what the projection field exists for. + +Promoting it opened a gap, and the gap is closed rather than noted: the +projection check only ever validated **partial** claims, so a `full` declared +over a short document — the over-claim that became reachable the moment nothing +was partial — was unchecked. It now validates both kinds, and the shadow cp2 +campaign's `M35` was re-anchored from the lie that is no longer possible +(declare full while partial) to the one that is (declare full while short). + +## 9. What checkpoint 5.3 landed + +BR-V9 had no golden of any kind. It has one now: a new fixture family built to +the Layer 3 pattern, and a Rust replay that compares the **bytes**. + +* **`ownlang/renders.py`** — an observer beside `verdicts.py`, imported by + nothing in the production path. It calls `render_finding` and `build_sarif` + and records what they returned: every format at both host severities, plus + one format `render_finding` does not know, so the fallback is *rendered* + rather than asserted equal to the human line. `RENDERS_VERSION` keys the + surface and the docstring freezes the normalization. +* **`tests/fixtures/verdict_renders/`** — targeted cases under a frozen + manifest ledger, each naming the BR-V9 rows it is the control for. Listed, + never swept: rendering the whole verdict corpus at two severities would + freeze megabytes to prove less than a handful of documents chosen for the + rules do. +* **`tests/test_verdict_render_fixtures.py`** — verify/`--write`, with stale, + missing and orphaned each a red build. +* **`own-bridge/tests/renders.rs`** — replays every case with zero Python and + compares byte for byte. Not a value comparison: SARIF key order is part of + this surface, and a value comparison would score a reordered log as + agreement. The Rust side reconstructs the document through typed structs + whose field order *is* the emitter's key order, because `serde_json`'s map + type sorts and would have quietly produced a different document. + +### Reuse where the format is shared, and the one place it is not + +`codeFlows` is `own_diagnostics::code_flow` verbatim — the two SARIF paths +genuinely share that projection. `relatedLocations` is **not**, and reusing it +would have been a bug: the core's builder drops a step whose file is empty +(an empty `artifactLocation.uri` makes a log unprocessable for GitHub code +scanning, the invariant `evidence.py` names), while the bridge's inline +comprehension in `ownir.py` filters on the line alone and emits the empty uri. +Python is the oracle, so the port reproduces the reference's behaviour and +`render_evidence_slices` is the golden that goes red if someone "simplifies" +the two into one. + +Nothing in `own-diagnostics` changed. The bridge's SARIF differs from the +core's in `properties`, `suppressions`, `region.startColumn` and the +`ownirSchemaVersion` driver stamp, so those are the bridge's own types. + +### The `subject` tail, closed + +Checkpoint 4 established that no Rust output surface serializes a diagnostic's +`subject` and promised to re-check once the bridge grew render and SARIF paths. +It is re-checked over the **bytes**, on both sides — the Python harness and the +Rust replay each scan the rendered document for a `subject` key — rather than +restated. `ownir.Finding` has no such member, so a bridge surface that emitted +one would be inventing a field. + +### Coverage + +Every BR-V9 row in the inventory ledger is pinned by at least one case, and the +join is computed rather than asserted: the row ledger lives in +`tests/verdict_surface_inventory.py`, the `pins` live in the fixture manifest, +and a row nobody pins reads `GAP: no control` while a case pinning a row the +ledger does not know is a hard problem. The counts are in +[the generated fragment](../generated/p022-cp5-inventory.md). + +## 10. What checkpoint 5.4 landed + +The status surfaces, brought level with what the tree proves — and nothing +more. + +* **The census** (`docs/generated/p022-cp4-census.md`) now describes the cp5 + comparison surface and counts the rendered-surface family beside the verdict + ledger, through `tests/verdict_render_census.py` — the one interpretation the + render harness and the inventory also use. The filename stays cp4's, because + that is where the fragment was introduced and two notes link it; what it + *describes* it says in its own first paragraph, which is the honest way to + keep one census for one ledger. +* **The mutation campaigns** for 5.1, 5.2 and 5.3 are registered like cp4's and + the shadow slice's, rendered into `docs/generated/p022-cp5-mutations.md` by + the same single interpreter and held by the same gate: a result that no + longer matches its definition, was taken on a dirty tree, missed a required + catcher, or names a commit this tree does not descend from is a red build. +* **`spec/Bridge.md` §6** and **`spec/BridgeBehaviorMatrix.md`** move the BR-V4 + wording rows and the BR-V9 rendering rows from "carried by the goldens, + compared at cp5" to compared — marked `L3 ✅` in the matrix, with the legend + saying what the mark means. The P-022 status row and the proposals index move + with them. +* **No count is typed** into any of them. Every number lives in a generated + fragment; the prose links. + +### The wording this checkpoint earns, and the wording it does not + +> Layer 3 parity over the measured set at the full `Finding` and the rendered +> surfaces; unmeasured set: protocol documents (row 4b), coordinate-domain +> controls (decision owed), OD-1 door controls. + +Not "verdict parity complete" — the protocol family is a whole analysis that +is not ported, and the bridge refuses rather than answers for it. Not "#259 +complete" — row 4b and the coordinate decision are both outstanding. Not +"shadow mode": the reducer still refuses the verdict layer, which is #260's +boundary. And not "P-022 done" by any reading. diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index b8469c6c..30486cf2 100644 --- a/docs/proposals/P-022-rust-core-migration.md +++ b/docs/proposals/P-022-rust-core-migration.md @@ -61,7 +61,7 @@ was #258 alone, which is satisfied. Per the checkpoints #259 itself defines: | 3 — interprocedural MOS | **complete for the stage-1 domain** | `dump_summaries()` byte-identical to `python -m ownlang summaries` across **35** `*.summaries.json` goldens. Container-valued metadata is **outside** the declared scalar-metadata parity domain — a separate #294-class door decision, not a silent gap | | 4 — analysis wiring | **complete at the checkpoint-4 surface — parity over the measured set, with the unmeasured set named** | `own_bridge::check_facts` is BR-V1 verbatim: the Layer 2 document rebuilt as the core AST (`ast.rs` — composing the cp2 evidence rather than lowering twice), `own_analysis::check_module` (ownership, lifetime, buffer policy), the `services[]`/`effects[]` blocks through the `own-analysis` DI and effect finders (BR-P1/P2), the OWN050/051/052 side paths, ERROR-only mapping through the verdict's **`subject`** to a fact handle with the reference's map-or-raise refusal (BR-V2/V3), the analysis-selected anchors preserved (DI004 call site, DI005 store site, OWN025 view site — BR-V5), tiering and suppression (BR-V6), dedup and the stable `(file, line, column, code)` sort (BR-V7/V8). Landing it needed one core change: `own-analysis` now stamps `subject` exactly where `analysis.py`/`lifetimes.py` pass `subject=sym.origin` (pinned through `check_module`). **Layer 3 fixture family built** (`ownlang/verdicts.py`, `tests/fixtures/verdicts/`, `tests/test_verdict_fixtures.py`; Rust replay `own-bridge/tests/verdicts.rs`): the goldens are Python's complete truth — the swept `ownir`/`lowered`/`summaries` corpora plus the synthetic controls — carrying every `Finding` member; the cp4 replay compares identity, anchor, kind and tiering (all members but `message`/`related`/`flow`) and **asserts** equality over the replayed set — every divergence collected without fail-fast, any one of them a red build, so the differential is a property of a green replay rather than a number to copy here. The rest is a **named exclusion ledger the replay executes**, not a silent gap — the documents declaring obligation protocols (the OBL analysis is not ported; the bridge *refuses* them rather than return a list with a family missing), the controls for the one representability boundary (a coordinate outside the core's `u32` line domain, which the strict door admits per `spec/OwnIR.md` §4.2 — refused, never clamped; the contract decision is one #259 still owes, and the stated direction is the Python-first tightening recorded on the cp5 row), and the #294 OD-1 controls where the reference's tolerant-door skip/ignore is unreachable through the typed Rust constructor. One declared comparison boundary on refusals: the map-or-raise text is compared up to its `message=` member, because the core's messages are still titles. **The measured census** (goldens by origin, exclusions by expectation, the replayed set with its refusals and findings) **and the recorded mutation campaign live only in the generated fragments** [`docs/generated/p022-cp4-census.md`](../generated/p022-cp4-census.md) and [`docs/generated/p022-cp4-mutations.md`](../generated/p022-cp4-mutations.md), rendered from the ledger and the campaign evidence by `scripts/render_checkpoint_status.py` and held in sync by the test gate — no count is typed in this table | | 4b — protocol analysis (OBL001–005) | **not started — recorded as its own checkpoint, not folded into cp5** | A gap in #259's checkpoint list: none of cp1–cp5 names the obligation-protocol analysis, while #259's final acceptance ("the full #258 family inventory") requires the protocol rows (BR-P3). `ownlang/obligations.py` is a path-sensitive analysis of its own — lattice, walker, matching — so it is a checkpoint of its own rather than a fourth job for cp5 (whose scope is messages, evidence and rendering). Its *normative* place: before #259's final acceptance; it does **not** block cp5. Until it lands the bridge **refuses** a protocol-bearing document (two reference documents in the verdict ledger) rather than return a verdict list with a family missing | -| 5 — full fact-to-verdict parity | **unblocked, not done** | the goldens already carry `message`, `related` and `flow`; cp5 is the message matrix (BR-V4), the evidence slices, the rendered surfaces (`render*`, `build_sarif` on the bridge path — BR-V9) and tightening the cp4 replay to full equality on the **same frozen goldens** — no regeneration beside the implementation. The `u32` coordinate boundary stays excluded until its contract decision lands; the owner's stated direction is a **Python-first tightening** of the coordinate domain (a negative source line is meaningless), as its own contract change with parity evidence — never "Rust is `u32`, so the reference is wrong" | +| 5 — full fact-to-verdict parity | **complete at the cp5 surface — Layer 3 parity over the measured set at the full `Finding` and the rendered surfaces** | the goldens already carry `message`, `related` and `flow`; cp5 is the message matrix (BR-V4), the evidence slices, the rendered surfaces (`render*`, `build_sarif` on the bridge path — BR-V9) and tightening the cp4 replay to full equality on the **same frozen goldens** — no regeneration beside the implementation. The **surface inventory** is taken and generated, never typed ([`p022-cp5-inventory.md`](../generated/p022-cp5-inventory.md), read by [the cp5 note](../notes/p022-bridge-verdict-checkpoint5.md)): one ledger row per BR-V4 wording branch with **who owns the string** (the bridge's matrix, the DI/effect finders' own `message`, or the core diagnostic's — which this core does not have at all, carrying each code's TITLE instead), one per BR-V5 evidence family and degradation rule, and one per BR-V9 rendered-surface rule; a row at zero over the replayed set is a branch cp5 must add a control for, not one it may declare. **cp5.1** put `message`, `related` and `flow` on `own_bridge::Finding`, ported the BR-V4 matrix and the BR-V5 slice builders, and tightened the replay to EVERY `Finding` member — against the goldens exactly as committed, none regenerated. The analysis-owned halves went where the reference keeps them: the DI finders' own `message` and ctor/registration metadata, and the effect storm's message, kind, mint line and reference chain, all additive in `own-analysis` with no existing core surface changed. Synthetic cases close every reachable branch the inventory named (insertion-stable: zero existing records rewritten, in the verdict ledger and in the shadow digest ledger alike); the branches no facts document can reach are pinned by `verdict::tests` controls driven through `map_core`, and their expected text is not written in those tests: it is read from `tests/fixtures/unreachable_branches.json`, the reference's own output recorded by `tests/test_unreachable_branch_probe.py` running `check_facts` with its lowering substituted — so the oracle's answer is re-runnable rather than a claim, and there is no second copy of the text for a port to drift into agreeing with. **cp5.2** removed the last comparison boundary on a refusal: `own_cfg::Diag` now carries the reference's message for the resolver text the BR-V3 map-or-raise line interpolates (measured to be the only one the corpus reaches), `own-analysis` reads it, and the three `hoist_neg_*` refusals compare byte for byte. The unported remainder is an `Option`, so a code whose text is missing renders as its title and the first golden that refuses on it goes red demanding the message — a tripwire, not a blind spot. Removing the cut immediately exposed the defect it was hiding: cp4's `py_repr` quoted unconditionally with `'`, where CPython switches to `"` for a string containing a single quote — which every core message naming an identifier does. Fixed in Rust and pinned against CPython's own output. One consequence in `own-shadow`: its verdict capture declared a *partial* projection whose reason said cp5 was not ported, so it now emits every member and declares `full`, with the projection check extended to validate a `full` claim too — the reducer still **refuses** the verdict layer, which is #260's boundary and unmoved. **cp5.3** built the surface that had no golden at all: `ownlang/renders.py` (an observer beside `verdicts.py`), the `tests/fixtures/verdict_renders/` family under its own frozen ledger where each case names the BR-V9 rows it is the control for, a verify/`--write` harness, and a Rust replay comparing the **bytes** — SARIF key order is part of this surface, so the port reconstructs the document through typed structs whose field order is the emitter's key order rather than a map type that sorts. `codeFlows` reuses `own_diagnostics::code_flow` verbatim; `relatedLocations` deliberately does **not**, because the core's builder drops a step with an empty file and the bridge's does not — reuse there would have been a port bug, and a golden pins the difference. `own-diagnostics` is unchanged on the core path. The checkpoint-4 `subject` tail is closed: both sides scan the rendered bytes for a `subject` key rather than restating that none can appear. What remains for #259's final acceptance is **row 4b** (the protocol analysis) and the coordinate-domain decision. The `u32` coordinate boundary stays excluded until its contract decision lands; the owner's stated direction is a **Python-first tightening** of the coordinate domain (a negative source line is meaningless), as its own contract change with parity evidence — never "Rust is `u32`, so the reference is wrong" | **Open steps — each owned by exactly one child issue under #250:** @@ -76,11 +76,14 @@ was #258 alone, which is satisfied. Per the checkpoints #259 itself defines: | 7b | Rust `own-cli`: command/output/exit-code parity | #261 | blocked — needs the production bridge and the output surfaces | | 8 | Rust-default **cutover**, rollback gate, Python distribution removal | #262 | blocked by #260/#261 and final parity | -**Preferred queue:** #259 cp5 → 4b (protocol analysis) → #260/#269. 4b does -not block cp5; #259's final acceptance needs it. The #260/#269 *infrastructure* -slice runs in parallel by design — it is measured on the landed checkpoints and -takes no position on the ones that are open, which is why its first checkpoint -could land without waiting on cp5. +**Preferred queue:** 4b (protocol analysis) → the coordinate-domain decision → +#259 final acceptance → #260 acceptance / #261. cp5 is off this queue: it is +complete at its surface, and what #259's final acceptance still needs is the +protocol family and the contract decision on coordinates. The #260/#269 +*infrastructure* slice ran in parallel by design — it is measured on the landed +checkpoints and takes no position on the ones that are open, which is why its +first checkpoint could land without waiting on cp5; its **acceptance** is a +different thing and waits on both items above. The defensive limits that used to head this queue landed in #326, and the order was load-bearing rather than tidy. cp1 could report 0/0/0 only over a set with diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 788dabc9..0c505738 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 underway (`own-lowered`/`own-bridge`, #259: lowering and MOS parity landed; strict-door validation complete at 216 controls 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, the replayed set asserted equal on the cp4 members with an executable exclusion ledger naming the protocol boundary, the `u32` coordinate boundary and the OD-1 door controls (the census is generated: `docs/generated/p022-cp4-census.md`); full fact-to-verdict parity (cp5: messages, evidence, rendered surfaces) open); step 7a shadow-mode INFRASTRUCTURE **complete for everything the row listed as sliceable now** — 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, and first-divergence reduction over the lowered/MOS layers); 80 documents digest-pinned and re-hashed with zero Python, 9 artifacts + 9 traces + 9 reductions reproduced byte-for-byte, campaigns 30/30, 11/11, 11/11 and 11/11 caught; computed classification left-only 0 / right-only 0 / changed 0 / ordering-only 0 / unexplained 0 over the lowered+MOS scope, with 2 declared-boundary status observations; four findings recorded and closed as contract decisions. NOT shadow mode and not parity: the reducer REFUSES the verdict layer and records the refusal, since acceptance compares end diagnostics and stays blocked by #259, and #260's raw-byte same-input invariant is not proved either — cp1 establishes shared CANONICAL document identity, which is the weaker claim; three departures from the slice's brief (checkpoint grouping, the `-0` domain narrowing, `sha2`) are ratified in [the owner-decision ledger](../notes/p022-shadow-infra-owner-decisions.md); Python authoritative until cutover | +| [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 underway (`own-lowered`/`own-bridge`, #259: lowering and MOS parity landed; strict-door validation complete at 216 controls 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 the protocol boundary, the `u32` coordinate boundary and the OD-1 door controls; **cp5 complete at its surface** — the replay now 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; what remains for #259's final acceptance is row 4b (the obligation-protocol analysis) and the coordinate-domain decision. Every count is generated: `docs/generated/p022-cp4-census.md` and `docs/generated/p022-cp5-inventory.md`); step 7a shadow-mode INFRASTRUCTURE **complete for everything the row listed as sliceable now** — 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, and first-divergence reduction over the lowered/MOS layers); the corpus digest-pinned and re-hashed with zero Python, its artifacts, traces and reductions reproduced byte-for-byte, every campaign fully caught, and the computed divergence classification zero on every axis over the lowered+MOS scope, with the declared-boundary status observations named — all of it counted in `docs/generated/p022-shadow-census.md` and `docs/generated/p022-shadow-mutations.md`, never here; the findings are recorded and closed as contract decisions. NOT shadow mode and not parity: the reducer REFUSES the verdict layer and records the refusal, since acceptance compares end diagnostics and stays blocked by #259, and #260's raw-byte same-input invariant is not proved either — cp1 establishes shared CANONICAL document identity, which is the weaker claim; three departures from the slice's brief (checkpoint grouping, the `-0` domain narrowing, `sha2`) are ratified in [the owner-decision ledger](../notes/p022-shadow-infra-owner-decisions.md); Python authoritative until cutover | | [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/ownlang/renders.py b/ownlang/renders.py new file mode 100644 index 00000000..468016ce --- /dev/null +++ b/ownlang/renders.py @@ -0,0 +1,92 @@ +"""Layer 3, rendered: the bridge's output surfaces (P-022 #259 checkpoint 5.3). + +`ownlang/verdicts.py` freezes what the bridge CONCLUDES — the `Finding` list. +This module freezes what a consumer SEES: the same list through +`ownir.render_finding` (the human CLI line, the GitHub Actions annotation, the +MSBuild/VS Error List line) and through `ownir.build_sarif` (the one SARIF +2.1.0 log per run). Those are BR-V9's surfaces, and until this family they had +no golden of any kind — `tests/test_ownir.py` pinned a handful of their rules +against hand-written strings, which is not the same thing as freezing the bytes +a run emits. + +Strictly an OBSERVER, like `ownlang/verdicts.py` and `ownlang/lowered.py`: it +never mutates facts, never changes a verdict or a rendering, and is imported by +nothing in the production path. It calls the production renderers and records +what they returned. + +Normalization decisions (frozen; changing any is a parity-contract change): + +* **Both host severities, always.** `severity` is a presentation choice that + the CLI passes through (`--severity`), and it is the one input to these + surfaces besides the findings. Every case renders at `"error"` and at + `"warning"` so the pass-through and the SARIF level mapping (BR-V6: an + advisory stays `note`, an intrinsic warning stays `warning`, a provable leak + drops from `error` to `warning`) are both visible in the golden. +* **One entry per finding, in the bridge's order** (BR-V8), for the three + line-per-finding surfaces. The list is never re-sorted here. +* **The unknown format is rendered, not assumed.** `render_finding` falls back + to the human line for any format it does not know; the golden carries that + rendering under its own key rather than a claim that it equals `human`. +* **SARIF is carried as the value `build_sarif` returned**, re-serialized by + this module's own `json.dumps`. The log is a JSON document, not a byte + stream the reference emits, so key ORDER is what the golden pins (Python + dicts preserve insertion order, and the Rust replay reproduces it). +* **A refusal** (`OwnIRError` from `check_facts`) projects as + `{"renders_version": ..., "error": ""}` — the same shape the Layer 3 + verdict surface uses, because a document the bridge refuses has no findings + to render and the refusal is what a consumer would see. +* Rendering is `json.dumps(indent=2, ensure_ascii=False)` + a trailing newline; + regeneration is deterministic for identical input. + +What this surface deliberately does NOT carry: a diagnostic's `subject`. +`ownir.Finding` has no such member, so no bridge surface can serialize one — +and `tests/test_verdict_render_fixtures.py` asserts that over the rendered +bytes rather than restating it (the checkpoint-4 note left that re-check to +this checkpoint). + +Fixture sharing: `tests/fixtures/verdict_renders/manifest.json` is the frozen +case ledger. Each case names its own facts document beside the manifest and the +BR-V9 rules it pins, which is what `tests/verdict_surface_inventory.py` reads +to say whether a rule has a control. +""" + +from __future__ import annotations + +import json +from typing import Any + +from .ownir import OwnIRError, build_sarif, check_facts, render_finding + +# The rendered-surface version. Bump on ANY normalization change above — the +# committed goldens and the Rust replay are both keyed to it. +RENDERS_VERSION = 1 + +# The host severities every case is rendered at (see the docstring). +SEVERITIES: tuple[str, ...] = ("error", "warning") +# The `render_finding` formats, plus one the function does not know: the +# fallback is a rule, so it is rendered rather than asserted. +FORMATS: tuple[str, ...] = ("human", "github", "msbuild", "unknown-format") + + +def project_renders(facts: dict[str, Any]) -> dict[str, Any]: + """Project one facts document's rendered surfaces into the canonical dict. + A bridge refusal projects as `{"renders_version": ..., "error": }`. + Never mutates `facts`.""" + try: + findings = check_facts(facts) + except OwnIRError as e: + return {"renders_version": RENDERS_VERSION, "error": str(e)} + out: dict[str, Any] = {"renders_version": RENDERS_VERSION} + for fmt in FORMATS: + out[fmt] = { + severity: [render_finding(f, fmt, severity) for f in findings] + for severity in SEVERITIES + } + out["sarif"] = {severity: build_sarif(findings, severity) for severity in SEVERITIES} + return out + + +def render_renders(facts: dict[str, Any]) -> str: + """The canonical serialized form: fixed key order, 2-space indent, non-ASCII + preserved, trailing newline. Byte-identical on re-run.""" + return json.dumps(project_renders(facts), indent=2, ensure_ascii=False) + "\n" diff --git a/pyproject.toml b/pyproject.toml index 18085ce4..2423aafb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ python_version = "3.11" files = [ "ownlang", "tests/verdict_census.py", + "tests/verdict_render_census.py", + "tests/verdict_surface_inventory.py", "scripts/mutate_campaign.py", "scripts/render_checkpoint_status.py", ] diff --git a/rust/crates/own-analysis/src/check.rs b/rust/crates/own-analysis/src/check.rs index 2e08bd39..687748d0 100644 --- a/rust/crates/own-analysis/src/check.rs +++ b/rust/crates/own-analysis/src/check.rs @@ -22,11 +22,15 @@ use own_diagnostics::{title, Diagnostic}; use crate::{lifetime, ownership}; -/// Convert an `own-cfg` resolver/policy diagnostic (code + line) into an -/// `own-diagnostics` value; the message is the title (text parity is a later -/// step). `code` is a compile-time constant, so construction cannot fail. +/// Convert an `own-cfg` resolver/policy diagnostic into an `own-diagnostics` +/// value, carrying the reference's message where `own-cfg` has it and the +/// code's title where it does not (#259 cp5.2 — see [`Diag::message`]). +/// `code` is a compile-time constant, so construction cannot fail. fn push_cfg_diag(out: &mut Vec, d: &Diag) { - let msg = title(d.code).unwrap_or(d.code); + let msg = d + .message + .as_deref() + .unwrap_or_else(|| title(d.code).unwrap_or(d.code)); match Diagnostic::new(d.code, msg, d.line) { Ok(x) => out.push(x), Err(_) => debug_assert!(false, "own-cfg emitted an unknown code {}", d.code), diff --git a/rust/crates/own-analysis/src/di.rs b/rust/crates/own-analysis/src/di.rs index 21ed5f8c..5cd11f26 100644 --- a/rust/crates/own-analysis/src/di.rs +++ b/rust/crates/own-analysis/src/di.rs @@ -43,11 +43,12 @@ impl Lifetime { } } -/// One DI registration. +/// One DI registration — every field of `di.Service`. /// -/// Mirrors the control-flow-relevant fields of `di.Service`; presentation-only -/// metadata (ctor/site tuples, used for evidence text) is omitted — evidence and -/// SARIF are a later step, out of #214. +/// #214 carried only the control-flow-relevant half; the consuming-constructor +/// metadata below is what a finding's message tail and its `related` anchor are +/// built from (#259 cp5), so it lands here rather than being re-derived by the +/// bridge: the finder owns the verdict, message included (spec/Bridge.md BR-B1). /// A `(type, file, line)` call/store site (a `root_resolve` or `scope_cache` /// site), the DI004/DI005 primary anchor. pub type SiteTriple = (String, String, u32); @@ -72,6 +73,15 @@ pub struct Service { pub scope_cached: Vec, /// `(type, file, line)` of each `scope_cached` field store — the DI005 primary. pub scope_cache_sites: Vec, + /// The consuming constructor — where the capture is injected. A secondary + /// anchor distinct from the registration site, and the input of the + /// ` [consumed by …]` message tail. Line `0` means unknown. + pub ctor_file: String, + pub ctor_line: u32, + /// The IMPLEMENTATION type owning that constructor. Named in the message + /// instead of the (possibly interface) service name; empty or `"?"` degrades + /// the tail to "the constructor" rather than guessing. + pub ctor_type: String, } impl Service { @@ -90,6 +100,9 @@ impl Service { root_resolve_sites: Vec::new(), scope_cached: Vec::new(), scope_cache_sites: Vec::new(), + ctor_file: "?".to_owned(), + ctor_line: 0, + ctor_type: String::new(), } } } @@ -106,6 +119,127 @@ pub struct DiFinding { pub path: Vec, pub file: String, pub line: u32, + /// The human verdict, byte-for-byte the reference's `message` property. The + /// bridge copies it and never rewords it (BR-B1). + pub message: String, + /// The singleton's **registration** `(file, line)`. Equal to `(file, line)` + /// for DI001/002/003; for DI004/DI005 it is the secondary anchor beside the + /// call/store-site primary, and the ` [singleton registered at …]` tail. + pub reg_file: String, + pub reg_line: u32, + /// The DI004 resolve-call / DI005 field-store line, `0` when the extractor + /// recorded none — the reference's `resolved_line` / `cached_line`. It is + /// what decides whether the primary came from a site (and therefore whether + /// the registration rides along as a `related` location), which `line` + /// alone cannot say once the fallback has been applied. + pub site_line: u32, + /// The consuming constructor (`Service::ctor_*` of the captor), `0` when + /// unknown. DI001/002/003 only — DI004/DI005 carry no consumer. + pub consumed_file: String, + pub consumed_line: u32, + pub consumed_type: String, +} + +/// ` [consumed by the '' constructor at :]` — the port of +/// `di._consumed_suffix`. Empty when the location is unknown; the type name is +/// dropped, never guessed, when only the location is known. +fn consumed_suffix(ctor_type: &str, file: &str, line: u32) -> String { + if line < 1 { + return String::new(); + } + let owner = if ctor_type.is_empty() || ctor_type == "?" { + "the constructor".to_owned() + } else { + format!("the '{ctor_type}' constructor") + }; + format!(" [consumed by {owner} at {file}:{line}]") +} + +/// ` [singleton registered at :]` — the DI004/DI005 tail, present +/// only when the primary anchor came from a call/store site AND the +/// registration line is known (otherwise the registration IS the primary). +fn registered_suffix(site_line: u32, reg_file: &str, reg_line: u32) -> String { + if site_line >= 1 && reg_line >= 1 { + format!(" [singleton registered at {reg_file}:{reg_line}]") + } else { + String::new() + } +} + +/// The five verdict messages, each a verbatim port of its dataclass `message` +/// property in `ownlang/di.py`. One function so the shared chain rendering and +/// the two tails cannot drift apart between codes. +fn di_message( + code: &str, + singleton: &str, + subject: &str, + path: &[String], + consumed: (&str, u32, &str), + registered: (u32, &str, u32), +) -> String { + let chain = path.join(" -> "); + let (ctor_file, ctor_line, ctor_type) = consumed; + let consumed_tail = consumed_suffix(ctor_type, ctor_file, ctor_line); + let (site_line, reg_file, reg_line) = registered; + let reg_tail = registered_suffix(site_line, reg_file, reg_line); + match code { + "DI001" => format!( + "singleton '{singleton}' captures scoped service '{subject}' \ + (captive dependency: {chain}){consumed_tail}" + ), + "DI002" => format!( + "singleton '{singleton}' weakly captures scoped service '{subject}' \ + (WeakReference): '{subject}' is still resolved from the root provider and \ + promoted to application lifetime — the weak reference avoids pinning it for \ + the GC but does not fix the captive-dependency lifetime violation \ + ({chain}){consumed_tail}" + ), + "DI003" => format!( + "singleton '{singleton}' captures transient IDisposable '{subject}': it is \ + promoted to application lifetime and disposed only when the root provider is \ + disposed ({chain}){consumed_tail}" + ), + "DI004" => format!( + "singleton '{singleton}' resolves transient IDisposable '{subject}' by hand \ + from its injected root IServiceProvider (GetService/GetRequiredService — the \ + service-locator anti-pattern): the root provider tracks every IDisposable it \ + resolves and frees them only at application shutdown, so each call leaks a \ + transient that should be scope-lived — resolve it from an IServiceScope \ + instead ({chain}){reg_tail}" + ), + _ => format!( + "singleton '{singleton}' caches scoped service '{subject}', resolved from a \ + scope it creates, into a field: the scope is disposed when the operation \ + ends, so the cached instance dangles (use-after-dispose) and '{subject}' is \ + promoted to application lifetime — the captive the scope was meant to avoid. \ + Resolve it inside the scope per use and do not cache it ({chain}){reg_tail}" + ), + } +} + +/// Fill in a finding's message from the members just set — one place, so a new +/// construction site cannot forget it and ship an empty verdict. +fn finished(mut f: DiFinding) -> DiFinding { + f.message = di_message( + f.code, + &f.singleton, + &f.subject, + &f.path, + (&f.consumed_file, f.consumed_line, &f.consumed_type), + (f.site_line, &f.reg_file, f.reg_line), + ); + f +} + +/// The raw call/store site line for `entry`, `0` when the extractor recorded +/// none — the reference's `sites.get(entry, ("?", 0))[1]`, last-wins on a +/// duplicate entry type exactly as [`primary_from_site`] reads it. +fn site_line(sites: &[SiteTriple], entry: &str) -> u32 { + sites + .iter() + .rev() + .find(|(ty, _, _)| ty == entry) + .map_or(0, |(_, _, line)| *line) } fn by_name(services: &[Service]) -> BTreeMap<&str, &Service> { @@ -170,14 +304,21 @@ pub fn find_captive_dependencies(services: &[Service]) -> Vec { match dnode.lifetime { Some(Lifetime::Scoped) => { if reported.insert(dep.clone()) { - findings.push(DiFinding { + findings.push(finished(DiFinding { code: "DI001", singleton: s.name.clone(), subject: dep.clone(), path: npath, file: s.file.clone(), line: s.line, - }); + message: String::new(), + reg_file: s.file.clone(), + reg_line: s.line, + site_line: 0, + consumed_file: s.ctor_file.clone(), + consumed_line: s.ctor_line, + consumed_type: s.ctor_type.clone(), + })); } // the violating edge is found; don't recurse past it. } @@ -214,14 +355,21 @@ pub fn find_weak_captive_dependencies(services: &[Service]) -> Vec { match cnode.lifetime { Some(Lifetime::Scoped) => { if reported.insert(cur.clone()) { - findings.push(DiFinding { + findings.push(finished(DiFinding { code: "DI002", singleton: s.name.clone(), subject: cur.clone(), path, file: s.file.clone(), line: s.line, - }); + message: String::new(), + reg_file: s.file.clone(), + reg_line: s.line, + site_line: 0, + consumed_file: s.ctor_file.clone(), + consumed_line: s.ctor_line, + consumed_type: s.ctor_type.clone(), + })); } } Some(Lifetime::Transient) if visited.insert(cur.clone()) => { @@ -263,14 +411,21 @@ pub fn find_captured_transient_disposables(services: &[Service]) -> Vec Vec { // registration is the fallback when the site is unknown. let entry = path.get(1).map_or("", String::as_str); let (pf, pl) = primary_from_site(&s.root_resolve_sites, entry, &s.file, s.line); - findings.push(DiFinding { + findings.push(finished(DiFinding { code: "DI004", singleton: s.name.clone(), subject: cur.clone(), path: path.clone(), file: pf, line: pl, - }); + message: String::new(), + reg_file: s.file.clone(), + reg_line: s.line, + site_line: site_line(&s.root_resolve_sites, entry), + consumed_file: "?".to_owned(), + consumed_line: 0, + consumed_type: String::new(), + })); } if visited.insert(cur.clone()) { for dep in &node.deps { @@ -357,14 +519,21 @@ pub fn find_scope_cached_captives(services: &[Service]) -> Vec { // (registration fallback when unknown). let (pf, pl) = primary_from_site(&s.scope_cache_sites, entry, &s.file, s.line); - findings.push(DiFinding { + findings.push(finished(DiFinding { code: "DI005", singleton: s.name.clone(), subject: cur.clone(), path, file: pf, line: pl, - }); + message: String::new(), + reg_file: s.file.clone(), + reg_line: s.line, + site_line: site_line(&s.scope_cache_sites, entry), + consumed_file: "?".to_owned(), + consumed_line: 0, + consumed_type: String::new(), + })); break; // first scoped reached — one finding per cached entry } Some(Lifetime::Transient) if visited.insert(cur.clone()) => { diff --git a/rust/crates/own-analysis/src/effect.rs b/rust/crates/own-analysis/src/effect.rs index 4a5b74a9..06f09622 100644 --- a/rust/crates/own-analysis/src/effect.rs +++ b/rust/crates/own-analysis/src/effect.rs @@ -64,13 +64,73 @@ pub struct Effect { /// /// `origin` is the upstream binding whose fresh identity is the root cause; /// `file`/`line` are the verdict's primary `(path, line)` — the effect call site. +/// +/// The remaining members carry the finding's **presentation**, which the +/// reference's `EffectStorm` owns rather than the bridge (`ownlang/effects.py`; +/// spec/Bridge.md BR-B1 — the analysis owns its verdict, message included): +/// `origin_kind` picks the kind phrase, `chain` supplies the `via …` clause, and +/// `decl_line` is where the unstable identity is minted — the second hop of the +/// bridge's two-step evidence slice (BR-V5). #[derive(Debug, Clone, PartialEq, Eq)] pub struct EffectStorm { pub component: String, pub dep: String, pub origin: String, + /// The `init` kind of the origin binding (`object`/`array`/`new`/…), or + /// `"object"` when the origin has no binding — the reference's fallback. + pub origin_kind: String, pub file: String, pub line: u32, + /// The origin binding's declaration line — the fix site. Falls back to the + /// effect's own line when the origin has no binding. + pub decl_line: u32, + /// The reference chain from the dependency to its origin. Renders as the + /// `via …` clause only when it has more than one element. + pub chain: Vec, +} + +impl EffectStorm { + /// The phrase naming what the origin is (`_kind_phrase`). + fn kind_phrase(&self) -> &'static str { + match self.origin_kind.as_str() { + "object" => "an object literal", + "array" => "an array literal", + "new" => "a freshly constructed object", + _ => "a value with an unstable identity", + } + } + + /// The EFF001 human message, byte-for-byte the reference's + /// `EffectStorm.message`. Owned here because the finder owns the verdict; + /// the bridge copies it and never rewords it. + #[must_use] + pub fn message(&self) -> String { + let phrase = self.kind_phrase(); + let root = if self.origin == self.dep { + format!( + "dependency '{}' is {phrase} created in render scope, so its identity \ + changes on every render", + self.dep + ) + } else { + let via = if self.chain.len() > 1 { + format!(" (via {})", self.chain.join(" -> ")) + } else { + String::new() + }; + format!( + "dependency '{}' derives from '{}', {phrase} created in render \ + scope{via}, so its identity changes on every render", + self.dep, self.origin + ) + }; + format!( + "effect re-runs on every render: {root}; the effect performs IO, which can \ + become a request storm — stabilise '{}' with useMemo/useCallback (or move \ + it out of render)", + self.origin + ) + } } fn kind_stability(init: &str) -> Option { @@ -112,6 +172,9 @@ struct Lattice<'a> { by_name: BTreeMap<&'a str, &'a Binding>, stab: BTreeMap, origin: BTreeMap, + /// The reference chain that carried the instability, captor first — the + /// `via a -> b -> c` clause of the EFF001 message (`_Lattice._path`). + path: BTreeMap>, } impl<'a> Lattice<'a> { @@ -120,6 +183,7 @@ impl<'a> Lattice<'a> { by_name: bindings.iter().map(|b| (b.name.as_str(), b)).collect(), stab: BTreeMap::new(), origin: BTreeMap::new(), + path: BTreeMap::new(), } } @@ -134,9 +198,20 @@ impl<'a> Lattice<'a> { .unwrap_or_else(|| name.to_owned()) } - fn resolve(&mut self, name: &str, on_stack: &BTreeSet) -> (Stability, String) { + fn path_of(&self, name: &str) -> Vec { + self.path + .get(name) + .cloned() + .unwrap_or_else(|| vec![name.to_owned()]) + } + + fn resolve( + &mut self, + name: &str, + on_stack: &BTreeSet, + ) -> (Stability, String, Vec) { if let Some(s) = self.stab.get(name) { - return (*s, self.origin_of(name)); + return (*s, self.origin_of(name), self.path_of(name)); } let Some(b) = self.by_name.get(name).copied() else { // No render-scope binding: an identifier/member chain is referentially @@ -146,41 +221,48 @@ impl<'a> Lattice<'a> { } else { Stability::Unknown }; - return (stab, name.to_owned()); + return (stab, name.to_owned(), vec![name.to_owned()]); }; if on_stack.contains(name) { // an identity cycle (a = b; b = a): cannot prove unstable — stay safe. - return (Stability::Unknown, name.to_owned()); + return (Stability::Unknown, name.to_owned(), vec![name.to_owned()]); } let mut next_stack = on_stack.clone(); next_stack.insert(name.to_owned()); - let (stab, origin) = self.classify(b, &next_stack); + let (stab, origin, path) = self.classify(b, &next_stack); self.stab.insert(name.to_owned(), stab); self.origin.insert(name.to_owned(), origin.clone()); - (stab, origin) + self.path.insert(name.to_owned(), path.clone()); + (stab, origin, path) } - fn classify(&mut self, b: &Binding, on_stack: &BTreeSet) -> (Stability, String) { + fn classify( + &mut self, + b: &Binding, + on_stack: &BTreeSet, + ) -> (Stability, String, Vec) { if let Some(s) = kind_stability(&b.init) { - return (s, b.name.clone()); + return (s, b.name.clone(), vec![b.name.clone()]); } if is_derived(&b.init) { if b.refs.is_empty() { - return (Stability::Unknown, b.name.clone()); + return (Stability::Unknown, b.name.clone(), vec![b.name.clone()]); } let mut worst = Stability::Stable; let mut worst_origin = b.name.clone(); + let mut worst_path = vec![b.name.clone()]; for r in &b.refs { - let (s, o) = self.resolve(r, on_stack); + let (s, o, p) = self.resolve(r, on_stack); if s.rank() > worst.rank() { worst = s; worst_origin = o; + worst_path = std::iter::once(b.name.clone()).chain(p).collect(); } } - return (worst, worst_origin); + return (worst, worst_origin, worst_path); } // "call" or any unrecognised kind: opaque identity -> conservative. - (Stability::Unknown, b.name.clone()) + (Stability::Unknown, b.name.clone(), vec![b.name.clone()]) } } @@ -199,10 +281,18 @@ pub fn find_effect_storms(effects: &[Effect]) -> Vec { if lat.stability(dep) != Stability::Unstable { continue; } + let origin = lat.origin_of(dep); + let binding = e.bindings.iter().find(|b| b.name == origin); out.push(EffectStorm { component: e.component.clone(), dep: dep.clone(), - origin: lat.origin_of(dep), + // `decl.get(origin, e.line)` and `b.init if b else "object"`: + // an origin with no binding of its own takes the effect's own + // line and the object phrasing. + origin_kind: binding.map_or("object", |b| b.init.as_str()).to_owned(), + decl_line: binding.map_or(e.line, |b| b.line), + chain: lat.path_of(dep), + origin, file: e.file.clone(), line: e.line, }); @@ -252,7 +342,61 @@ pub fn effect_diagnostics(effects: &[Effect]) -> Vec { clippy::indexing_slicing )] mod tests { - use super::{find_effect_storms, Binding, Effect}; + use super::{find_effect_storms, Binding, Effect, EffectStorm}; + + /// The `via …` clause is guarded on a chain longer than one hop, and the + /// guard cannot be exercised end to end: a storm only words "derives from" + /// when `origin != dep`, and reaching a different origin means walking at + /// least one reference, so the chain always has two entries there. The + /// guard is defensive on both sides of the port (`ownlang/effects.py` + /// writes the same `len(self.path) > 1`) — dropping it would put an empty + /// `(via )` into user-visible text. + /// + /// Both expected sentences are the REFERENCE's, read from the record + /// `tests/fixtures/unreachable_branches.json` that + /// `tests/test_unreachable_branch_probe.py` produces by constructing the + /// reference's own `EffectStorm`. Nothing here restates them, so this + /// control cannot drift into agreeing with the port instead of with Python. + #[test] + fn the_via_clause_is_omitted_for_a_single_hop_chain() { + const RECORDED: &str = include_str!("../../../../tests/fixtures/unreachable_branches.json"); + let doc: serde_json::Value = + serde_json::from_str(RECORDED).expect("the probe record parses"); + assert_eq!( + doc.get("probe_version").and_then(serde_json::Value::as_u64), + Some(1), + "the probe record changed shape — teach this control the new version" + ); + let oracle = |key: &str| { + doc.get("messages") + .and_then(|m| m.get(key)) + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("the probe record carries no '{key}'")) + .to_owned() + }; + let storm = EffectStorm { + component: "W".to_owned(), + dep: "cfg".to_owned(), + origin: "opts".to_owned(), + origin_kind: "object".to_owned(), + file: "W.tsx".to_owned(), + line: 5, + decl_line: 2, + chain: vec!["opts".to_owned()], + }; + assert_eq!( + storm.message(), + oracle("eff001_single_hop_chain_has_no_via") + ); + let with_hops = EffectStorm { + chain: vec!["cfg".to_owned(), "opts".to_owned()], + ..storm + }; + assert_eq!( + with_hops.message(), + oracle("eff001_multi_hop_chain_has_via") + ); + } fn binding(name: &str, init: &str, refs: &[&str]) -> Binding { Binding { diff --git a/rust/crates/own-analysis/tests/fact_parity.rs b/rust/crates/own-analysis/tests/fact_parity.rs index 3df4e693..f7bad6d2 100644 --- a/rust/crates/own-analysis/tests/fact_parity.rs +++ b/rust/crates/own-analysis/tests/fact_parity.rs @@ -116,6 +116,19 @@ fn service_from(v: &Value) -> Service { root_resolve_sites: sites(v.get("root_resolve_sites")), scope_cached: strs(v.get("scope_cached")), scope_cache_sites: sites(v.get("scope_cache_sites")), + // Presentation metadata (#259 cp5): absent in a fact-parity case, which + // compares `(file, line, code)`; the reference's own defaults apply. + ctor_file: v + .get("ctor_file") + .and_then(Value::as_str) + .unwrap_or("?") + .to_owned(), + ctor_line: u32_of(v.get("ctor_line")), + ctor_type: v + .get("ctor_type") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(), } } diff --git a/rust/crates/own-bridge/Cargo.toml b/rust/crates/own-bridge/Cargo.toml index 64466ec5..460c433a 100644 --- a/rust/crates/own-bridge/Cargo.toml +++ b/rust/crates/own-bridge/Cargo.toml @@ -25,9 +25,10 @@ own-cfg = { path = "../own-cfg" } own-analysis = { path = "../own-analysis" } own-diagnostics = { path = "../own-diagnostics" } serde_json.workspace = true - -[dev-dependencies] -# The summaries-family manifest is parsed typed+strict in tests/summaries.rs. +# The BR-V9 SARIF log (#259 cp5.3) is typed structs whose FIELD ORDER is the +# surface: the reference builds Python dicts and `json.dumps` writes them in +# insertion order, so the shape is checked at compile time rather than +# assembled as a free-form value. serde.workspace = true [lints] diff --git a/rust/crates/own-bridge/src/lib.rs b/rust/crates/own-bridge/src/lib.rs index a7602d75..e7939a87 100644 --- a/rust/crates/own-bridge/src/lib.rs +++ b/rust/crates/own-bridge/src/lib.rs @@ -40,12 +40,14 @@ mod ast; mod dump; mod lower; mod mos; +mod render; mod verdict; use own_ir::OwnIr; use own_lowered::LoweredDocument; -pub use verdict::Finding; +pub use render::{build_sarif, render_finding, SarifLog}; +pub use verdict::{Finding, Step}; /// A lowering rejection — the Rust twin of Python's `OwnIRError` from /// `to_module`. The message TEXT is part of the Layer 2 parity surface diff --git a/rust/crates/own-bridge/src/render.rs b/rust/crates/own-bridge/src/render.rs new file mode 100644 index 00000000..cebf95d0 --- /dev/null +++ b/rust/crates/own-bridge/src/render.rs @@ -0,0 +1,395 @@ +//! The bridge's OUTPUT surfaces — `ownlang/ownir.py`'s `render_finding` and +//! `build_sarif` (spec/Bridge.md BR-V9), at the #259 checkpoint-5.3 surface. +//! +//! This is the second SARIF builder in the tree, and the split is deliberate. +//! `own_diagnostics::sarif` is the `.own` flow-diagnostic path (#256): one file +//! per run, `Diagnostic` values, no `properties`, no `suppressions`, no +//! `startColumn`, no schema stamp. The bridge's is the C#/DI path over +//! `ownir.Finding`: a file per finding, the subscription triple in +//! `properties`, `[OwnIgnore]` in `suppressions`, the #317 column in the +//! region, and the `ownirSchemaVersion` stamp on the driver. Where the two +//! formats coincide the core's builder is REUSED rather than re-derived — the +//! `codeFlows` projection is `own_diagnostics::code_flow` verbatim. +//! +//! **Where they do not coincide, reuse would be a bug.** The core's +//! `related_locations` drops a step with an empty file (an empty +//! `artifactLocation.uri` makes a log unprocessable for GitHub code scanning — +//! the invariant `evidence.py` calls out by name); the bridge's `relatedLocations` +//! is an inline comprehension in `ownir.py` that filters on the LINE alone and +//! emits the empty uri. That difference is the reference's, not a defect this +//! port may tidy, so this module builds its own — and +//! `tests/fixtures/verdict_renders/render_evidence_slices` is the golden that +//! goes red if someone "simplifies" it into the shared builder. +//! +//! Serialization order is part of the surface: the reference builds Python +//! dicts and `json.dumps` writes them in insertion order, so every struct here +//! declares its fields in that order and `skip_serializing_if` reproduces the +//! keys the reference splices in conditionally. + +use own_diagnostics::{code_flow, title, Step as CoreStep}; +use serde::Serialize; + +use crate::verdict::{Finding, Step}; + +/// `_SARIF_SCHEMA` / `_SARIF_INFO_URI` / the tool identity, from `ownir.py`. +const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"; +const SARIF_INFO_URI: &str = "https://github.com/PhysShell/Own.NET"; +const SARIF_VERSION: &str = "2.1.0"; +const TOOL_NAME: &str = "Owen"; +/// `OWNIR_VERSION` — the schema stamp the driver carries so a consumer can tell +/// which fact vocabulary produced the log. +const OWNIR_VERSION: u32 = 0; + +/// `_esc_data`: a GitHub workflow-command MESSAGE escapes only `%`, CR and LF. +fn esc_data(s: &str) -> String { + s.replace('%', "%25") + .replace('\r', "%0D") + .replace('\n', "%0A") +} + +/// `_esc_prop`: a workflow-command PROPERTY value additionally treats `:` and +/// `,` as separators. Applied on top of the message escaping, in that order — +/// the `%` of a `%3A` must not be re-escaped, which is why `_esc_data` runs +/// first and the two extra replacements after it. +fn esc_prop(s: &str) -> String { + esc_data(s).replace(':', "%3A").replace(',', "%2C") +} + +/// `Finding.render` — the human CLI line. +fn render_human(f: &Finding, severity: &str) -> String { + format!( + "{}:{}: {severity}: [{}] {} [resource: {}]", + f.file, f.line, f.code, f.message, f.kind + ) +} + +/// `Finding.render_github` — a GitHub Actions workflow annotation. +fn render_github(f: &Finding, severity: &str) -> String { + let message = format!("[{}] {} [resource: {}]", f.code, f.message, f.kind); + format!( + "::{severity} file={},line={},title={}::{}", + esc_prop(&f.file), + f.line, + esc_prop(&f.code), + esc_data(&message) + ) +} + +/// `Finding.render_msbuild` — `file(line): severity CODE: message`, the format +/// `dotnet build` and the VS Error List parse. +fn render_msbuild(f: &Finding, severity: &str) -> String { + format!( + "{}({}): {severity} {}: {} [resource: {}]", + f.file, f.line, f.code, f.message, f.kind + ) +} + +/// `render_finding`: the human line is the fallback for any format this +/// surface does not know — a typo in `--format` must not silence a finding. +#[must_use] +pub fn render_finding(f: &Finding, fmt: &str, severity: &str) -> String { + match fmt { + "github" => render_github(f, severity), + "msbuild" => render_msbuild(f, severity), + _ => render_human(f, severity), + } +} + +#[derive(Debug, Serialize)] +struct ArtifactLocation { + uri: String, +} + +/// The bridge's region: the 1-based line, and the #317 column only when the +/// producer reported one AND a line is being emitted at all. +#[derive(Debug, Serialize)] +struct Region { + #[serde(rename = "startLine")] + start_line: i64, + #[serde(rename = "startColumn", skip_serializing_if = "Option::is_none")] + start_column: Option, +} + +#[derive(Debug, Serialize)] +struct PhysicalLocation { + #[serde(rename = "artifactLocation")] + artifact_location: ArtifactLocation, + #[serde(skip_serializing_if = "Option::is_none")] + region: Option, +} + +#[derive(Debug, Serialize)] +struct Message { + text: String, +} + +#[derive(Debug, Serialize)] +struct Location { + #[serde(rename = "physicalLocation")] + physical_location: PhysicalLocation, +} + +#[derive(Debug, Serialize)] +struct RelatedLocation { + #[serde(rename = "physicalLocation")] + physical_location: PhysicalLocation, + message: Message, +} + +/// `[OwnIgnore("reason")]` (#209): a suppressed finding stays in `results` so a +/// consumer counts it, and carries why. +#[derive(Debug, Serialize)] +struct Suppression { + kind: &'static str, + justification: String, +} + +#[derive(Debug, Serialize)] +struct Properties { + #[serde(rename = "resourceKind")] + resource_kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + component: Option, + #[serde(skip_serializing_if = "Option::is_none")] + event: Option, + #[serde(skip_serializing_if = "Option::is_none")] + handler: Option, +} + +#[derive(Debug, Serialize)] +struct SarifResult { + #[serde(rename = "ruleId")] + rule_id: String, + level: String, + message: Message, + locations: Vec, + properties: Properties, + #[serde(rename = "relatedLocations", skip_serializing_if = "Vec::is_empty")] + related_locations: Vec, + #[serde(rename = "codeFlows", skip_serializing_if = "Vec::is_empty")] + code_flows: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + suppressions: Vec, +} + +#[derive(Debug, Serialize)] +struct Rule { + id: String, + #[serde(rename = "shortDescription")] + short_description: Message, +} + +#[derive(Debug, Serialize)] +struct DriverProperties { + #[serde(rename = "ownirSchemaVersion")] + ownir_schema_version: u32, +} + +#[derive(Debug, Serialize)] +struct Driver { + name: &'static str, + #[serde(rename = "informationUri")] + information_uri: &'static str, + rules: Vec, + properties: DriverProperties, +} + +#[derive(Debug, Serialize)] +struct Tool { + driver: Driver, +} + +#[derive(Debug, Serialize)] +struct Run { + tool: Tool, + results: Vec, +} + +/// A complete SARIF 2.1.0 log from the bridge path. +#[derive(Debug, Serialize)] +pub struct SarifLog { + #[serde(rename = "$schema")] + schema: &'static str, + version: &'static str, + runs: Vec, +} + +/// `_sarif_level`: an advisory is SARIF's dedicated `note` — a consumer can +/// tell "could not check this" from a warning-tier leak, which the flat +/// error/warning surfaces cannot express. The host severity only ever lowers. +fn sarif_level(f: &Finding, severity: &str) -> String { + if f.advisory { + return "note".to_owned(); + } + if severity == "warning" || f.severity.as_deref() == Some("warning") { + return "warning".to_owned(); + } + "error".to_owned() +} + +/// The primary `physicalLocation`. Backslashes fold to forward slashes so a +/// Windows-produced log stays consumable; `region` is omitted entirely for a +/// file-level finding, and a column never appears without a line. +fn primary_location(f: &Finding) -> PhysicalLocation { + PhysicalLocation { + artifact_location: ArtifactLocation { + uri: f.file.replace('\\', "/"), + }, + region: (f.line >= 1).then_some(Region { + start_line: f.line, + start_column: f.column, + }), + } +} + +/// The bridge's `relatedLocations`. See the module docs: this filters on the +/// LINE alone, so a step whose file is empty emits an empty uri — the +/// reference's behaviour, and NOT `own_diagnostics::related_locations`'. +fn related_locations(related: &[Step]) -> Vec { + related + .iter() + .filter(|(_, line, _)| *line >= 1) + .map(|(file, line, label)| RelatedLocation { + physical_location: PhysicalLocation { + artifact_location: ArtifactLocation { + uri: file.replace('\\', "/"), + }, + region: Some(Region { + start_line: *line, + start_column: None, + }), + }, + message: Message { + text: label.clone(), + }, + }) + .collect() +} + +/// The ordered `codeFlows`, through the core's own builder — the one place the +/// two SARIF paths genuinely share a format (`evidence.code_flow`). +fn code_flows(flow: &[Step]) -> Vec { + let steps: Vec> = flow + .iter() + .filter_map(|(file, line, label)| { + u32::try_from(*line) + .ok() + .map(|line| CoreStep { file, line, label }) + }) + .collect(); + code_flow(&steps) +} + +fn optional(value: &str) -> Option { + (!value.is_empty()).then(|| value.to_owned()) +} + +fn sarif_result(f: &Finding, severity: &str) -> SarifResult { + SarifResult { + rule_id: f.code.clone(), + level: sarif_level(f, severity), + message: Message { + text: format!("{} [resource: {}]", f.message, f.kind), + }, + locations: vec![Location { + physical_location: primary_location(f), + }], + properties: Properties { + resource_kind: f.kind.clone(), + component: optional(&f.component), + event: optional(&f.event), + handler: optional(&f.handler), + }, + related_locations: related_locations(&f.related), + code_flows: code_flows(&f.flow), + suppressions: f + .ignore_reason + .as_ref() + .map(|reason| Suppression { + kind: "inSource", + justification: reason.clone(), + }) + .into_iter() + .collect(), + } +} + +/// `build_sarif`: the whole run as one SARIF 2.1.0 log. +/// +/// One `run` whose driver is Owen, with a rule catalogue of the codes PRESENT +/// (sorted and deduplicated, each with its `TITLES` text, an unknown code +/// falling back to itself) and one result per finding in the bridge's own +/// order. Two orderings in one log, on purpose: sorting `results` would +/// destroy the BR-V8 order the tie-breaking depends on. +#[must_use] +pub fn build_sarif(findings: &[Finding], severity: &str) -> SarifLog { + let mut codes: Vec<&str> = findings.iter().map(|f| f.code.as_str()).collect(); + codes.sort_unstable(); + codes.dedup(); + SarifLog { + schema: SARIF_SCHEMA, + version: SARIF_VERSION, + runs: vec![Run { + tool: Tool { + driver: Driver { + name: TOOL_NAME, + information_uri: SARIF_INFO_URI, + rules: codes + .into_iter() + .map(|code| Rule { + id: code.to_owned(), + short_description: Message { + text: title(code).unwrap_or(code).to_owned(), + }, + }) + .collect(), + properties: DriverProperties { + ownir_schema_version: OWNIR_VERSION, + }, + }, + }, + results: findings.iter().map(|f| sarif_result(f, severity)).collect(), + }], + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] +mod tests { + use super::{related_locations, Step}; + + /// The bridge's `relatedLocations` drops a step whose line is unknown. No + /// facts document can produce one — `_consumer_related` and the DI004/DI005 + /// builders each require their line to be `>= 1` before they emit a step at + /// all — so the filter is defensive, and the rendered goldens cannot + /// exercise it. It is the reference's rule either way, and a port that + /// dropped it would emit `"startLine": 0`, which is not a SARIF coordinate. + /// + /// The empty-FILE half of this builder is the opposite case and needs no + /// unit test: it is reachable, and `render_evidence_slices` is its golden. + #[test] + fn a_related_step_with_no_line_is_dropped() { + let steps: Vec = vec![ + ("a.cs".to_owned(), 0, "no line".to_owned()), + ("b.cs".to_owned(), -3, "negative line".to_owned()), + ("c.cs".to_owned(), 4, "kept".to_owned()), + ]; + let out = related_locations(&steps); + assert_eq!(out.len(), 1, "only the resolvable step survives"); + assert_eq!(out[0].message.text, "kept"); + assert_eq!( + out[0] + .physical_location + .region + .as_ref() + .expect("a kept step carries its region") + .start_line, + 4 + ); + } +} diff --git a/rust/crates/own-bridge/src/verdict.rs b/rust/crates/own-bridge/src/verdict.rs index 4d48c1cb..21138979 100644 --- a/rust/crates/own-bridge/src/verdict.rs +++ b/rust/crates/own-bridge/src/verdict.rs @@ -1,10 +1,19 @@ //! The verdict mapping — `ownlang/ownir.py::check_facts` (spec/Bridge.md §5), -//! at the #259 checkpoint-4 surface: every finding's **identity and anchor** -//! (`file`, `line`, `column`, `code`, `component`, `event`, `handler`), its -//! `kind`, its tiering (`advisory`, `severity`) and its suppression -//! (`ignore_reason`) — everything the reference's dedup key and sort key are -//! made of, except the human `message`. Message synthesis (BR-V4), the -//! `related`/`flow` evidence slices and the rendered surfaces are checkpoint 5. +//! at the #259 checkpoint-5 surface: every member of the reference's `Finding`. +//! Identity and anchor (`file`, `line`, `column`, `code`, `component`, `event`, +//! `handler`), `kind`, tiering (`advisory`, `severity`), suppression +//! (`ignore_reason`) — and, added here, the synthesized `message` (BR-V4) and +//! the ordered `related`/`flow` evidence slices (BR-V5). The rendered surfaces +//! (BR-V9) are checkpoint 5.3 and live beside this module, never inside it. +//! +//! **Three owners write the message, and only one of them is here.** The BR-V4 +//! matrix below synthesizes the wording for every mapped core verdict and for +//! the OWN050/051/052 advisories. The DI and effect verdicts carry the +//! `own-analysis` finder's own message verbatim — the analysis owns its verdict +//! (BR-B1), so rewording it here would be the bridge repairing a verdict. And +//! two flow-local branches interpolate the **core diagnostic's** message, which +//! this core still carries as the code's title; that is the one message the +//! bridge cannot supply, and it is checkpoint 5.2's. //! //! The pipeline is BR-V1 verbatim: lower → core `check_module` → map the //! ERROR-severity core diagnostics only, skipping the closed BR-V2 artifact @@ -19,16 +28,13 @@ //! boundary; the verdict fixture ledger records the two reference documents //! this excludes. //! -//! **Dedup key, minus the message.** The reference deduplicates on +//! **The dedup key is complete.** The reference deduplicates on //! `(file, line, column, code, component, event, handler, message, kind, -//! advisory, severity, ignore_reason)`. This checkpoint carries every member -//! but `message`. That is not a weakening on the reference's own outputs: -//! every message is a function of the finding's handle record and code (the -//! flow-local wordings key on `code`/`pool`/`ever_released`, the token -//! wordings on the record, and the same-handle same-code duplicates BR-V7 -//! exists for are byte-identical), so two findings equal on the carried key -//! are equal on the message too. The fixture replay measures it on the whole -//! corpus; checkpoint 5 adds the member itself. +//! advisory, severity, ignore_reason)` — every member except `related`/`flow` +//! (OD-5: two findings differing only in evidence collapse to the first). cp4 +//! carried every member but `message` and argued it was exact on the +//! reference's own outputs; the argument is now unnecessary, because the member +//! is here. // The mapping mirrors `check_facts` branch-for-branch; `expect()` marks // invariant-backed reads (a value the same function just inserted). @@ -48,10 +54,13 @@ use own_analysis::effect::{self, Binding, Effect}; use own_diagnostics::{Diagnostic, Severity}; use own_ir::OwnIr; use serde_json::Value; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; + +/// One evidence step: `(file, line, label)` — the reference's triple, and the +/// shape the Layer 3 golden serializes as a three-element array. +pub type Step = (String, i64, String); -/// One bridge finding — the Rust twin of `ownir.Finding` at the checkpoint-4 -/// surface (the `message`, `related` and `flow` members land with checkpoint 5). +/// One bridge finding — the Rust twin of `ownir.Finding`, every member. /// /// Field semantics are the reference's: `line` is the C# anchor (the fact's /// own line — `0` when the fact carries none, as for the anchorless OWN052); @@ -61,7 +70,9 @@ use std::collections::HashSet; /// `"warning"` tier of an unprovable-lifetime subscription or a DI002–005 /// verdict, `None` for a provable leak shown at the host's severity; /// `ignore_reason` is the `[OwnIgnore("…")]` justification of a suppressed -/// (still counted) finding. +/// (still counted) finding; `message` is the human verdict (BR-V4); `related` +/// is the unordered set of secondary anchors and `flow` the ORDERED +/// reachability slice (BR-V5), each empty for a single-point finding. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Finding { pub file: String, @@ -71,9 +82,12 @@ pub struct Finding { pub component: String, pub event: String, pub handler: String, + pub message: String, pub kind: String, pub advisory: bool, pub severity: Option, + pub related: Vec, + pub flow: Vec, pub ignore_reason: Option, } @@ -87,9 +101,12 @@ impl Finding { component: String::new(), event: String::new(), handler: String::new(), + message: String::new(), kind: kind.to_owned(), advisory: false, severity: None, + related: Vec::new(), + flow: Vec::new(), ignore_reason: None, } } @@ -98,6 +115,164 @@ impl Finding { /// BR-V2: the closed list of bridge-artifact core codes dropped before mapping. const SKIP: [&str; 5] = ["OWN033", "OWN034", "OWN035", "OWN040", "OWN041"]; +/// BR-V4: the inline-lambda note, appended verbatim wherever the record's +/// `lambda` is truthy. A lambda handler has no `-=` handle, so it could never +/// be detached even deliberately — the reference spells that out on the plain +/// subscription wording and on both OWN014 wordings, and nowhere else. +const LAMBDA_NOTE: &str = " — and being an inline lambda it has no '-=' handle, \ + so it could never be detached"; + +fn lambda_note(rec: &Obj) -> &'static str { + if truthy(rec.get("lambda")) { + LAMBDA_NOTE + } else { + "" + } +} + +/// BR-V4, the flow-local half of the matrix: the wording splits on the code, +/// on `pool` (an `ArrayPool` rent is *returned*, not disposed) and — for +/// OWN001 only — on `ever_released`, which separates "released on no path" from +/// "released on some but not all". +/// +/// `core_message` is the core diagnostic's own message, used by the two fallback +/// branches a code with no wording of its own takes. This core still carries +/// each code's TITLE there (checkpoint 5.2), which is why the fallback is +/// reached by no case in the measured corpus and by a control that says so. +fn flow_local_message( + code: &str, + name: &str, + pool: bool, + ever_released: bool, + core_message: &str, +) -> String { + if code == "OWN001" { + return match (pool, ever_released) { + (true, true) => format!( + "pooled buffer '{name}' may not be returned to the pool on every path (leak)" + ), + (true, false) => { + format!("pooled buffer '{name}' is rented but never returned to the pool (leak)") + } + (false, true) => { + format!("IDisposable local '{name}' may not be disposed on every path (leak)") + } + (false, false) => format!("IDisposable local '{name}' is never disposed (leak)"), + }; + } + if pool { + return match code { + "OWN002" => format!("pooled buffer '{name}' is used after it is returned to the pool"), + "OWN003" => { + format!("pooled buffer '{name}' is returned to the pool more than once") + } + "OWN009" => { + format!("pooled buffer '{name}' may be used after being returned on some path") + } + _ => format!("pooled buffer '{name}': {core_message}"), + }; + } + match code { + "OWN002" => format!("IDisposable local '{name}' is used after it is disposed"), + "OWN003" => format!("IDisposable local '{name}' is disposed more than once"), + "OWN009" => { + format!("IDisposable local '{name}' may be used after disposal on some path") + } + _ => format!("IDisposable local '{name}': {core_message}"), + } +} + +/// `_FLOW_LOCAL_VIOLATION`: the label of the site where a flow-local +/// obligation is violated. A plain leak (OWN001) has no second site — the +/// acquire IS the finding — so it is absent here and gets no slice. +fn flow_local_violation(code: &str) -> Option<&'static str> { + match code { + "OWN002" => Some("used here after it was released/returned"), + "OWN003" => Some("released/returned here a second time"), + "OWN009" => Some("may be used here after release on some path"), + "OWN025" => Some("viewed here at full length, past what it was rented for"), + _ => None, + } +} + +/// BR-V5: the two-step flow-local slice — the Rent/acquire site (where the +/// resource came from) → the site where its obligation is violated. Empty when +/// either line is unknown or the two sites coincide, which is the "a slice +/// shorter than two steps is dropped" rule in its concrete form here. +fn flow_local_steps(rec: &Obj, code: &str, dline: i64, pool: bool) -> Vec { + let Some(violation) = flow_local_violation(code) else { + return Vec::new(); + }; + let acquire = as_int(rec.get("line")); + if acquire < 1 || dline < 1 || dline == acquire { + return Vec::new(); + } + let file = get_or(rec, "file", "?"); + let name = get_or(rec, "event", "?"); + let origin = if pool { + format!("rented '{name}' here") + } else { + format!("acquired '{name}' here") + }; + vec![ + (file.clone(), acquire, origin), + (file, dline, violation.to_owned()), + ] +} + +/// BR-V4: the `nice` phrase for a DI-registered source's lifetime. An +/// unrecognised lifetime is named, not hidden — the reference interpolates it. +fn di_life_phrase(life: &str) -> String { + match life { + "singleton" => "a DI singleton (application-lifetime) service".to_owned(), + "scoped" => "a DI scoped service".to_owned(), + "transient" => "a DI transient service".to_owned(), + other => format!("a DI {other} service"), + } +} + +/// BR-V5: a DI dependency path rendered as ordered evidence steps, each hop +/// anchored at that service's registration site (`evidence.di_path_steps`). +/// +/// A hop whose registration site is unknown is **skipped**, leaving the slice +/// ordered and truthful rather than wrong. Note what this builder does NOT do: +/// it has no "shorter than two steps" guard, so a path with exactly one +/// resolvable hop emits a ONE-step `flow`. Only the OWN014 escape slice, the +/// flow-local slice and the effect slice drop a short slice. +fn di_path_steps( + path: &[String], + loc: &BTreeMap, + end_label: &str, +) -> Vec { + let last = path.len().saturating_sub(1); + path.iter() + .enumerate() + .filter_map(|(i, name)| { + let (file, line) = loc.get(name.as_str())?; + let label = if i == 0 { + format!("singleton '{name}' (captor)") + } else if i == last { + format!("{end_label} '{name}'") + } else { + format!("via '{name}'") + }; + Some((file.clone(), *line, label)) + }) + .collect() +} + +/// The end-of-path label each DI family uses — the one word that says what the +/// captor did with the service it reached. +const fn di_end_label(code: &str) -> &'static str { + match code.as_bytes() { + b"DI002" => "weakly captures scoped service", + b"DI003" => "captures transient IDisposable", + b"DI004" => "leaks transient IDisposable", + b"DI005" => "caches scoped service", + _ => "captures scoped service", + } +} + /// Python `str(v)` for the JSON scalars a record field can hold (the /// containers a scalar field cannot hold on either door are rendered as /// JSON text — the same boundary `lower.rs` states for `py_str`). @@ -132,10 +307,58 @@ fn truthy(v: Option<&Value>) -> bool { } } -/// Python `{x!r}` for the values the map-or-raise message interpolates: a -/// subject is `None` or a simple string, repr'd with single quotes. +/// Python `{x!r}` for the two values the map-or-raise message interpolates: +/// `None`, or a string through `CPython`'s `repr`. +/// +/// The quote choice is the load-bearing half, and cp4's placeholder got it +/// wrong because the comparison was cut before it: `CPython` uses `'` unless the +/// string contains a `'` and no `"`, in which case it switches to `"` rather +/// than escaping. Every core message that quotes a name — `undefined name +/// 'loc_0'` — takes that branch, so a single-quote-always port differs on +/// every one of them. +/// +/// Escaping covers the backslash, the active quote and the ASCII control +/// range, which is `CPython`'s rule for every character a diagnostic message can +/// hold; printable non-ASCII (the em dash the wordings use) passes through +/// unescaped, as it does there. A NON-printable non-ASCII character would +/// diverge, and cannot occur: these messages are built from source identifiers +/// and fixed English text. fn py_repr(v: Option<&str>) -> String { - v.map_or_else(|| "None".to_owned(), |s| format!("'{s}'")) + let Some(s) = v else { + return "None".to_owned(); + }; + let quote = if s.contains('\'') && !s.contains('"') { + '"' + } else { + '\'' + }; + let mut out = String::with_capacity(s.len().saturating_add(2)); + out.push(quote); + for ch in s.chars() { + match ch { + '\\' => out.push_str("\\\\"), + c if c == quote => { + out.push('\\'); + out.push(c); + } + '\t' => out.push_str("\\t"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + c if c.is_ascii_control() => { + // `\xNN`, lowercase hex, exactly as `CPython` writes it. An + // ASCII control fits in one byte, so both nibbles are digits. + let byte = u32::from(c); + out.push_str("\\x"); + for shift in [4_u32, 0] { + let nibble = (byte >> shift) & 0xf; + out.push(char::from_digit(nibble, 16).unwrap_or('0')); + } + } + c => out.push(c), + } + } + out.push(quote); + out } /// `_route_resource(rkind)[1]`: the `[resource: …]` label of an owned kind — @@ -197,6 +420,7 @@ fn is_mapped(d: &Diagnostic) -> bool { fn map_core( diags: &[Diagnostic], records: &std::collections::HashMap, + svc_loc: &BTreeMap, ) -> Result, BridgeError> { let mut out = Vec::new(); for d in diags.iter().filter(|d| is_mapped(d)) { @@ -223,10 +447,17 @@ fn map_core( let column = as_col(rec.get("column")); if rkind == "flow-local" { let pool = truthy(rec.get("pool")); + let name = &event; if d.code == "OWN025" { // the VIEW site (the core's line), never the acquire's column. let mut f = Finding::new(file, i64::from(d.line), "OWN025", "pooled buffer"); f.component = component; + f.message = format!( + "pooled buffer '{name}' is viewed at its full length, past the \ + logical length it was rented for (over-read / over-clear)" + ); + // the pool wording is forced here: an `overspan` is always a rent. + f.flow = flow_local_steps(rec, &d.code, i64::from(d.line), true); f.event = event; out.push(f); continue; @@ -239,13 +470,91 @@ fn map_core( ); f.column = column; f.component = component; + f.message = flow_local_message( + &d.code, + name, + pool, + truthy(rec.get("ever_released")), + &d.message, + ); + f.flow = flow_local_steps(rec, &d.code, i64::from(d.line), pool); + f.event = event; + out.push(f); + continue; + } + if truthy(rec.get("di_source_life")) { + // OWN014 region escape sourced from the DI graph: the injected event + // SOURCE is registered with a lifetime that outlives the subscriber, + // so the strong subscription promotes the component to it. Error-tier. + let life = rec.get("di_source_life").map_or_else(String::new, py_str); + let source_type = rec.get("source_type"); + let type_name = source_type.map_or_else(|| "?".to_owned(), py_str); + let mut f = Finding::new(file.clone(), anchor, &d.code, "subscription token"); + f.message = format!( + "event '{event}' is subscribed (handler '{handler}') to '{type_name}' — {} \ + that outlives '{component}'; the strong subscription promotes \ + '{component}' to the source's lifetime, so it can never be collected — a \ + captive/region escape (leak, no release path{})", + di_life_phrase(&life), + lambda_note(rec), + ); + // BR-V5: the subscribe site -> where the longer-lived source was + // registered. The source hop is present only when the services graph + // knows that registration; a lone first step is dropped. + if anchor >= 1 { + // `svc_loc.get(st)` in the reference: an ABSENT `source_type` + // looks the literal "?" up, and a non-string one can match no + // key at all (the map is keyed by `str(name)`). + let key = match source_type { + None => Some("?".to_owned()), + Some(Value::String(name)) => Some(name.clone()), + Some(_) => None, + }; + if let Some((sf, sl)) = key.and_then(|k| svc_loc.get(&k)).filter(|(_, l)| *l >= 1) { + f.flow = vec![ + ( + file, + anchor, + format!("'{component}' subscribes '{event}' to '{type_name}' here"), + ), + ( + sf.clone(), + *sl, + format!( + "source '{type_name}' ({life}) registered here — outlives \ + '{component}'" + ), + ), + ]; + } + } + f.column = column; + f.component = component; f.event = event; + f.handler = handler; + f.ignore_reason = ir; out.push(f); continue; } - if truthy(rec.get("di_source_life")) || rkind == "capture" { - // OWN014 region escape: DI-sourced or a static capture — error-tier. + if rkind == "capture" { + // OWN014 region escape from the capture route (`event += handler` + // fire-and-forget): no token to release, so a provable leak at + // error tier. No escape slice — there is no registration hop. + let source = get_str(rec, "source").unwrap_or("?"); + let origin = if source == "static" { + "a static (process-lived) event source".to_owned() + } else { + let named = rec.get("source").map_or_else(|| "?".to_owned(), py_str); + format!("a longer-lived source ('{named}')") + }; let mut f = Finding::new(file, anchor, &d.code, "subscription token"); + f.message = format!( + "event '{event}' is subscribed (handler '{handler}') to {origin} that \ + outlives '{component}'; the strong subscription promotes '{component}' \ + to the source's lifetime, so it can never be collected — a region escape \ + (leak, no release path{})", + lambda_note(rec), + ); f.column = column; f.component = component; f.event = event; @@ -264,7 +573,51 @@ fn map_core( _ if injected => Some("warning".to_owned()), _ => None, }; + // BR-V4, the token half of the matrix. `of_type` is a TRUTHINESS test + // on the record's `type`: an empty string adds no parenthetical, the + // same rule the core's `kind_suffix` follows for its own tag. + let of_type = rec + .get("type") + .filter(|v| truthy(Some(v))) + .map_or_else(String::new, |v| format!(" (type '{}')", py_str(v))); + let message = match rkind { + "timer" => format!( + "timer '{event}' (handler '{handler}') is started but never stopped or \ + detached — the running timer keeps '{component}' alive (leak)" + ), + "disposable" => format!( + "IDisposable field '{event}'{of_type} is never disposed — its owner \ + '{component}' leaks it (leak)" + ), + "local-disposable" => { + format!("local IDisposable '{event}'{of_type} is created but never disposed (leak)") + } + "subscribe" if injected => format!( + "the result of '{event}' is ignored — its IDisposable subscription is \ + never disposed; the source is an injected dependency whose lifetime is \ + unknown, so it may outlive and keep '{component}' alive (possible leak)" + ), + "subscribe" => format!( + "the result of '{event}' is ignored — the IDisposable subscription is \ + never disposed, leaking '{component}' (leak)" + ), + "pool" => { + format!("pooled buffer '{event}' is rented but never returned to the pool (leak)") + } + _ if injected => format!( + "event '{event}' is subscribed (handler '{handler}') but never \ + unsubscribed; its source is an injected dependency whose lifetime is \ + unknown, so it may outlive and keep '{component}' alive (possible leak{})", + lambda_note(rec), + ), + _ => format!( + "event '{event}' is subscribed (handler '{handler}') but never \ + unsubscribed — the source keeps '{component}' alive (leak{})", + lambda_note(rec), + ), + }; let mut f = Finding::new(file, anchor, &d.code, kind); + f.message = message; f.column = column; f.component = component; f.event = event; @@ -354,13 +707,49 @@ fn di_findings(root: &Obj) -> Result, BridgeError> { s.get("scope_cache_sites"), &format!("{what} scope_cache_sites"), )?, + ctor_file: get_or(s, "ctor_file", "?"), + ctor_line: guarded_line(as_int(s.get("ctor_line")), &what)?, + ctor_type: get_or(s, "ctor_type", ""), name, }); } + // BR-P1: the registration site of every service whose line is known — the + // anchor of each hop of a finding's retention path. + let loc_by_name: BTreeMap = services + .iter() + .filter(|s| s.line >= 1) + .map(|s| (s.name.clone(), (s.file.clone(), i64::from(s.line)))) + .collect(); Ok(di::all_di_findings(&services) .into_iter() .map(|c| { let mut f = Finding::new(c.file, i64::from(c.line), c.code, "DI lifetime"); + f.message = c.message; + f.flow = di_path_steps(&c.path, &loc_by_name, di_end_label(c.code)); + // BR-V5: DI001/002/003 anchor at the registration and point at the + // consuming constructor; DI004/DI005 anchor at the call/store site + // and point back at the registration — but only when the site is + // what they anchored on (otherwise the registration IS the primary). + f.related = if c.code == "DI004" || c.code == "DI005" { + if c.site_line >= 1 && c.reg_line >= 1 { + vec![( + c.reg_file, + i64::from(c.reg_line), + format!("registration of singleton '{}'", c.singleton), + )] + } else { + Vec::new() + } + } else if c.consumed_line >= 1 { + let owner = if c.consumed_type.is_empty() || c.consumed_type == "?" { + "consuming constructor".to_owned() + } else { + format!("consuming constructor of '{}'", c.consumed_type) + }; + vec![(c.consumed_file, i64::from(c.consumed_line), owner)] + } else { + Vec::new() + }; f.component = c.singleton; f.event = c.subject; // DI003/002/004/005 are real verdicts shown at `warning`; DI001 is @@ -439,7 +828,27 @@ fn effect_findings(root: &Obj) -> Result, BridgeError> { Ok(effect::find_effect_storms(&effects) .into_iter() .map(|s| { - let mut f = Finding::new(s.file, i64::from(s.line), "EFF001", "react effect"); + let mut f = Finding::new(s.file.clone(), i64::from(s.line), "EFF001", "react effect"); + f.message = s.message(); + // BR-V5: where the effect re-fires -> where the unstable identity is + // minted (the fix site). Both lines must be real, or no slice. + if s.line >= 1 && s.decl_line >= 1 { + f.flow = vec![ + ( + s.file.clone(), + i64::from(s.line), + format!("effect re-runs here on '{}'", s.dep), + ), + ( + s.file, + i64::from(s.decl_line), + format!( + "'{}' gets a fresh identity here — stabilise with useMemo", + s.origin + ), + ), + ]; + } f.component = s.component; f.event = s.dep; f @@ -474,6 +883,11 @@ fn unresolved_findings(root: &Obj) -> Vec { f.component.clone_from(&cname); f.event = get_or(sub, "event", "?"); f.handler = get_or(sub, "handler", "?"); + f.message = format!( + "cannot verify '{}' — its declaring type is an unresolved reference \ + (build the project or pass references); leakage analysis skipped", + f.event + ); f.advisory = true; out.push(f); } @@ -486,12 +900,19 @@ fn transfer_note(a: &Own051) -> Finding { f.component.clone_from(&a.component); f.event.clone_from(&a.arg); f.handler.clone_from(&a.callee); + f.message = format!( + "cannot verify whether '{}' takes ownership of '{}' (inferred contract: {}); \ + optimistically assuming it does — '{}' is not checked past this call", + a.callee, a.arg, a.transfer, a.arg + ); f.advisory = true; f } -/// The dedup key (BR-V7) at this checkpoint — see the module docs for the -/// one member (`message`) it does not yet carry, and why that is exact here. +/// The dedup key (BR-V7): every `Finding` member EXCEPT `related` and `flow`. +/// Excluding the evidence is the reference's own rule and a recorded open +/// decision (OD-5) — two findings differing only in evidence collapse to the +/// first — not a shortcut taken here. type DedupKey = ( String, i64, @@ -501,11 +922,26 @@ type DedupKey = ( String, String, String, + String, bool, Option, Option, ); +/// BR-V7 applied: first occurrence wins. +/// +/// A named function rather than three lines inside `check_facts`, because it is +/// the only place three members of the key can be shown to matter. With +/// `message` in the key (cp5.1), `event`, `kind` and `severity` became +/// **unobservable** end to end: every wording that varies with them names them, +/// so no facts document can produce two findings equal on the message and +/// differing on one of those. They are still the reference's key members, and +/// the control for them has to drive this function directly — see the test. +fn dedup(findings: &mut Vec) { + let mut seen: HashSet = HashSet::new(); + findings.retain(|f| seen.insert(dedup_key(f))); +} + fn dedup_key(f: &Finding) -> DedupKey { ( f.file.clone(), @@ -515,6 +951,7 @@ fn dedup_key(f: &Finding) -> DedupKey { f.component.clone(), f.event.clone(), f.handler.clone(), + f.message.clone(), f.kind.clone(), f.advisory, f.severity.clone(), @@ -550,24 +987,39 @@ pub(crate) fn check_facts(facts: &OwnIr) -> Result, BridgeError> { let module = ast::to_module(&lowering.doc)?; let diags = own_analysis::check_module(&module); - let mut findings = map_core(&diags, &lowering.handles)?; + // BR-V5: the registration site of every DI service, used to anchor the + // source hop of an OWN014 escape slice. Built from the RAW records (a + // malformed entry is skipped, never coerced), like the reference's. + let mut svc_loc: BTreeMap = BTreeMap::new(); + if let Some(Value::Array(raw)) = root.get("services") { + for entry in raw.iter().filter_map(Value::as_object) { + svc_loc.insert( + get_or(entry, "name", ""), + (get_or(entry, "file", "?"), as_int(entry.get("line"))), + ); + } + } + + let mut findings = map_core(&diags, &lowering.handles, &svc_loc)?; findings.extend(di_findings(root)?); findings.extend(effect_findings(root)?); // protocol findings would append here (refused above until wired). findings.extend(unresolved_findings(root)); findings.extend(lowering.advisories.iter().map(transfer_note)); let module_name = root.get("module").map_or_else(|| "?".to_owned(), py_str); - for _reason in &lowering.mos_notes { + for reason in &lowering.mos_notes { // anchorless by nature: file-level, module-scoped (BR-V5). let mut f = Finding::new("?", 0, "OWN052", "method summaries"); f.component.clone_from(&module_name); + f.message = format!( + "interprocedural summary inference failed ({reason}); method summaries \ + skipped — cross-method ownership transfer was not checked this run" + ); f.advisory = true; findings.push(f); } - // BR-V7: first occurrence wins. - let mut seen: HashSet = HashSet::new(); - findings.retain(|f| seen.insert(dedup_key(f))); + dedup(&mut findings); // BR-V8: a STABLE sort — ties keep insertion order; an absent column // sorts as 0, before every real (>= 1) one, and is never emitted as such. findings.sort_by(|a, b| { @@ -583,15 +1035,42 @@ pub(crate) fn check_facts(facts: &OwnIr) -> Result, BridgeError> { #[cfg(test)] #[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)] mod tests { - use super::{di_findings, effect_findings, map_core, Obj}; + use super::{dedup, di_findings, effect_findings, map_core, Finding, Obj}; use own_diagnostics::{Diagnostic, Severity}; use serde_json::{json, Value}; + use std::collections::BTreeMap; use std::collections::HashMap; fn obj(v: &Value) -> Obj { v.as_object().cloned().unwrap() } + /// The reference's own wording for a branch no facts document can reach, + /// read from `tests/fixtures/unreachable_branches.json`. + /// + /// The controls below do NOT carry their own copy of these strings. The + /// file is what `tests/test_unreachable_branch_probe.py` recorded by + /// running `check_facts` with its lowering and core substituted — the only + /// way to ask the oracle about a state its own inputs cannot construct — + /// and reading it here is what makes "the reference says so" a re-runnable + /// fact instead of a claim about how carefully someone read `ownir.py`. + /// Compiled in, so a stale fixture is a build-time change, not a runtime + /// file lookup inside a unit test. + fn oracle(key: &str) -> String { + const RECORDED: &str = include_str!("../../../../tests/fixtures/unreachable_branches.json"); + let doc: Value = serde_json::from_str(RECORDED).expect("the probe record parses"); + assert_eq!( + doc.get("probe_version").and_then(Value::as_u64), + Some(1), + "the probe record changed shape — teach these controls the new version" + ); + doc.get("messages") + .and_then(|m| m.get(key)) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("the probe record carries no '{key}'")) + .to_owned() + } + /// BR-V1: only ERROR-severity core diagnostics are mapped. The production /// path cannot produce a sub-error core verdict today (no facts producer /// reaches the buffer-policy pass that grades below ERROR), so the rule is @@ -615,7 +1094,8 @@ mod tests { let warning = error.clone().with_severity(Severity::Warning); let artifact = Diagnostic::new("OWN033", "return type", 0).unwrap(); - let mapped = map_core(&[error], &records).unwrap(); + let no_services = BTreeMap::new(); + let mapped = map_core(&[error], &records, &no_services).unwrap(); assert_eq!( mapped .iter() @@ -624,15 +1104,195 @@ mod tests { vec![("Vm.cs", 7, "OWN001")] ); assert!( - map_core(&[warning], &records).unwrap().is_empty(), + map_core(&[warning], &records, &no_services) + .unwrap() + .is_empty(), "a sub-error core diagnostic is not a verdict (BR-V1)" ); assert!( - map_core(&[artifact], &records).unwrap().is_empty(), + map_core(&[artifact], &records, &no_services) + .unwrap() + .is_empty(), "a BR-V2 artifact is dropped before map-or-raise, subject or not" ); } + /// BR-V3's refusal text quotes the core message through `CPython`'s `repr`, + /// and the quote choice is not decoration: every core message that names an + /// identifier contains a `'`, so `repr` switches to `"` rather than + /// escaping. cp4 shipped a single-quote-always placeholder because the + /// comparison was cut before this member; cp5.2 removed the cut and the + /// three `hoist_neg_*` goldens went red on it immediately. + /// + /// Expected values below are `repr()` output, taken from `CPython`. + #[test] + fn py_repr_matches_cpython_including_the_quote_switch() { + for (input, want) in [ + (None, "None"), + (Some("undefined name 'loc_0'"), "\"undefined name 'loc_0'\""), + (Some("has \"double\" only"), "'has \"double\" only'"), + (Some("both ' and \""), "'both \\' and \"'"), + (Some("plain"), "'plain'"), + (Some("tab\there"), "'tab\\there'"), + (Some("nl\nhere"), "'nl\\nhere'"), + (Some("back\\slash"), "'back\\\\slash'"), + (Some("em — dash"), "'em — dash'"), + (Some("ctrl\u{1}byte"), "'ctrl\\x01byte'"), + (Some("both ' and \" and \\"), "'both \\' and \" and \\\\'"), + ] { + assert_eq!(super::py_repr(input), want, "repr({input:?})"); + } + } + + /// BR-V7's three unobservable key members. `event`, `kind` and `severity` + /// cannot be dropped from the key by any facts document once `message` is + /// in it: every wording that varies with one of them interpolates it, so a + /// pair equal on the message and differing on one of the three does not + /// exist downstream of `check_facts`. The members are the reference's all + /// the same, so the control drives `dedup` — the production function — on + /// pairs assembled here instead. (`handler` and `component` DO have such + /// pairs, and `verdict_dedup_key_members` is the golden that carries them.) + #[test] + fn dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member() { + let base = Finding::new("A.cs", 4, "OWN001", "subscription token"); + for mutate in [ + (|f: &mut Finding| f.event = "other".to_owned()) as fn(&mut Finding), + |f: &mut Finding| f.kind = "timer".to_owned(), + |f: &mut Finding| f.severity = Some("warning".to_owned()), + ] { + let mut twin = base.clone(); + mutate(&mut twin); + let mut both = vec![base.clone(), twin]; + dedup(&mut both); + assert_eq!( + both.len(), + 2, + "BR-V7's key must separate these; message is equal on both" + ); + } + let mut identical = vec![base.clone(), base]; + dedup(&mut identical); + assert_eq!(identical.len(), 1, "first occurrence wins"); + } + + /// BR-V4's flow-local FALLBACK: a code with no wording of its own keeps the + /// core diagnostic's message verbatim after a colon, on both sides of the + /// pool split. Driven through `map_core` — the production branch — because + /// no facts document can reach it: the `OwnIR` flow vocabulary is nine ops, + /// and the only codes they raise on a flow-local handle are OWN001/002/003/ + /// 009/025, every one of which HAS a wording. Same shape as the BR-V1 + /// severity control above: a rule the corpus cannot exercise is proven at + /// the unit level rather than left unproven or quietly dropped. + /// + /// The expected text of this test and the two below is the REFERENCE's own + /// output, not a reading of its source, and it is not written here either: + /// [`oracle`] reads it out of the record + /// `tests/fixtures/unreachable_branches.json`, which + /// `tests/test_unreachable_branch_probe.py` produces by running + /// `check_facts` with its lowering substituted. A probe, not a golden — a + /// golden would need a facts document, which is exactly what does not + /// exist — but a re-runnable one. + #[test] + fn a_flow_local_code_without_a_wording_keeps_the_core_message() { + let mut records: HashMap = HashMap::new(); + for (handle, pool) in [("loc_0", false), ("loc_1", true)] { + records.insert( + handle.to_owned(), + obj(&json!({ + "resource": "flow-local", "event": "s", "line": 4, "pool": pool, + "component": "C.M", "file": "A.cs", "ever_released": false + })), + ); + } + let diags: Vec = ["loc_0", "loc_1"] + .iter() + .map(|h| { + Diagnostic::new("OWN005", "moved 's' at A.cs:9", 9) + .unwrap() + .with_subject(format!("{h}#4")) + }) + .collect(); + let no_services = BTreeMap::new(); + let got: Vec = map_core(&diags, &records, &no_services) + .unwrap() + .into_iter() + .map(|f| f.message) + .collect(); + assert_eq!( + got, + vec![ + oracle("flow_local_fallback_plain"), + oracle("flow_local_fallback_pooled"), + ] + ); + } + + /// BR-V4's DI lifetime phrases. `singleton` and `scoped` have goldens; the + /// other two are defensive and say so here. A `transient` source can outlive + /// no subscriber — `transient < scoped < Process` and `Subscriber < Process` + /// leave nothing shorter than transient — so the lifetime engine never + /// reports the escape; and an unrecognised lifetime never reaches + /// `di_source_life` at all, because the DI life map admits only the three. + #[test] + fn every_di_lifetime_phrase_is_pinned_including_the_unreachable_two() { + let mut records: HashMap = HashMap::new(); + let mut diags = Vec::new(); + for (i, life) in ["transient", "gremlin"].iter().enumerate() { + let handle = format!("cap_{i}"); + records.insert( + handle.clone(), + obj(&json!({ + "event": "src.E", "handler": "OnE", "line": 7, "component": "Vm", + "file": "Vm.cs", "source": "injected", "source_type": "Src", + "di_source_life": life + })), + ); + diags.push( + Diagnostic::new("OWN014", "escape", 7) + .unwrap() + .with_subject(format!("{handle}#7")), + ); + } + let no_services = BTreeMap::new(); + let got: Vec = map_core(&diags, &records, &no_services) + .unwrap() + .into_iter() + .map(|f| f.message) + .collect(); + // The full sentence, not a substring, and not a sentence written here: + // both come from the recorded probe. + assert_eq!( + got, + vec![ + oracle("own014_di_transient"), + oracle("own014_di_unknown_lifetime"), + ] + ); + } + + /// BR-V4's capture-route origin phrase. The static wording has goldens; the + /// named-source one is defensive — routing R3 mints a handle only for a + /// source with a declared capture region, and `static` is the only entry in + /// that table, so a capture with any other source is skipped at lowering and + /// never reaches a verdict. + #[test] + fn the_capture_route_names_a_non_static_source_it_can_never_be_handed() { + let mut records: HashMap = HashMap::new(); + records.insert( + "cap_0".to_owned(), + obj(&json!({ + "resource": "capture", "event": "svc.E", "handler": "OnE", "line": 9, + "component": "Vm", "file": "Vm.cs", "source": "container" + })), + ); + let diags = vec![Diagnostic::new("OWN014", "escape", 9) + .unwrap() + .with_subject("cap_0#9")]; + let no_services = BTreeMap::new(); + let got = map_core(&diags, &records, &no_services).unwrap(); + assert_eq!(got[0].message, oracle("own014_capture_named_source")); + } + /// BR-D2 tolerance (1): a malformed effect entry is SKIPPED as a whole, /// never coerced into a spurious verdict. Pinned at the raw-document level /// on purpose: the crate's public entry is the typed `OwnIr` constructor, diff --git a/rust/crates/own-bridge/tests/renders.rs b/rust/crates/own-bridge/tests/renders.rs new file mode 100644 index 00000000..3b2e71aa --- /dev/null +++ b/rust/crates/own-bridge/tests/renders.rs @@ -0,0 +1,246 @@ +//! The checkpoint-5.3 acceptance contract (#259): for every case of the +//! rendered-surface fixture family, +//! +//! ```text +//! facts.json → OwnIr → own_bridge::check_facts → render_finding / build_sarif +//! ≡ .renders.json BYTE FOR BYTE +//! ``` +//! +//! The golden is what the reference's own renderers returned +//! (`ownlang/renders.py`, regenerate: `python +//! tests/test_verdict_render_fixtures.py --write`). This replay reconstructs +//! the whole document — every format at both host severities, the SARIF log at +//! both — serializes it with the same conventions the emitter fixes (2-space +//! indent, non-ASCII preserved, a trailing newline) and compares the BYTES. +//! Not a value comparison: SARIF key order is part of the surface here, and a +//! value comparison would score a reordered log as agreement. +//! +//! Independently enforced, not outsourced to Python: +//! * the ledger is the tree — every manifest case has facts and a golden, and +//! every facts/golden file on disk is a manifest case; +//! * every case pins at least one BR-V9 ledger row, and the rows the manifest +//! claims are the rows the inventory reads; +//! * no rendered surface carries a diagnostic `subject` (the checkpoint-4 +//! subject tail, re-checked over the bytes on this side too); +//! * every case is deterministic. + +#![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)] + +use own_bridge::{build_sarif, check_facts, render_finding, Finding, SarifLog}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +const FIXDIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/verdict_renders" +); + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + #[allow(dead_code)] + comment: String, + renders_version: u32, + cases: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + name: String, + rules: Vec, + pins: Vec, +} + +/// One surface at both host severities. A struct rather than a map because the +/// emitter's KEY ORDER is part of the golden and `serde_json`'s map type sorts. +#[derive(Serialize)] +struct PerSeverity { + error: T, + warning: T, +} + +/// The whole rendered document, in the emitter's key order +/// (`ownlang/renders.py`). `skip_serializing_if` reproduces the two shapes it +/// emits: a refusal carries `error` and nothing else; a success carries the +/// four rendered formats and the SARIF pair. +#[derive(Serialize)] +struct RenderedDocument { + renders_version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + human: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + github: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + msbuild: Option>>, + #[serde(rename = "unknown-format", skip_serializing_if = "Option::is_none")] + unknown_format: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + sarif: Option>, +} + +fn read(path: &str) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| { + panic!( + "cannot read {path}: {e} — regenerate: \ + python tests/test_verdict_render_fixtures.py --write" + ) + }) +} + +fn stems(suffix: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + for entry in std::fs::read_dir(FIXDIR).expect("fixture directory is readable") { + let file = entry.expect("directory entry").file_name(); + let file = file.to_str().expect("fixture filenames are UTF-8"); + if let Some(stem) = file.strip_suffix(suffix) { + out.insert(stem.to_owned()); + } + } + out +} + +/// The emitter's serialization: `json.dumps(indent=2, ensure_ascii=False)` plus +/// a trailing newline. `serde_json`'s pretty printer uses the same two-space +/// indent and leaves non-ASCII unescaped, so the two agree on the bytes. +fn serialize(doc: &RenderedDocument) -> String { + let mut out = serde_json::to_string_pretty(doc).expect("the projection serializes"); + out.push('\n'); + out +} + +fn lines_of(findings: &[Finding], fmt: &str) -> PerSeverity> { + let render = |severity: &str| { + findings + .iter() + .map(|f| render_finding(f, fmt, severity)) + .collect() + }; + PerSeverity { + error: render("error"), + warning: render("warning"), + } +} + +/// Rebuild one case's whole rendered document. +fn project(facts_text: &str) -> RenderedDocument { + let empty = RenderedDocument { + renders_version: 1, + error: None, + human: None, + github: None, + msbuild: None, + unknown_format: None, + sarif: None, + }; + let facts: own_ir::OwnIr = serde_json::from_str(facts_text) + .unwrap_or_else(|door| panic!("the typed door refused a rendered-surface case: {door}")); + match check_facts(&facts) { + Err(refusal) => RenderedDocument { + error: Some(refusal.to_string()), + ..empty + }, + Ok(findings) => RenderedDocument { + human: Some(lines_of(&findings, "human")), + github: Some(lines_of(&findings, "github")), + msbuild: Some(lines_of(&findings, "msbuild")), + unknown_format: Some(lines_of(&findings, "unknown-format")), + sarif: Some(PerSeverity { + error: build_sarif(&findings, "error"), + warning: build_sarif(&findings, "warning"), + }), + ..empty + }, + } +} + +#[test] +fn replays_every_rendered_surface_byte_for_byte() { + let manifest: Manifest = serde_json::from_str(&read(&format!("{FIXDIR}/manifest.json"))) + .expect("manifest.json parses (typed, strict)"); + assert_eq!( + manifest.renders_version, 1, + "manifest renders_version must match this replay's surface version" + ); + + let mut planned: BTreeSet = BTreeSet::new(); + let mut pinned: BTreeSet = BTreeSet::new(); + for case in &manifest.cases { + assert!( + !case.rules.is_empty(), + "case '{}' must name the BR rules it pins", + case.name + ); + assert!( + !case.pins.is_empty(), + "case '{}' pins no BR-V9 ledger row — a rendered golden nobody can read a \ + claim off is not evidence", + case.name + ); + assert!( + planned.insert(case.name.clone()), + "duplicate manifest case name: {}", + case.name + ); + pinned.extend(case.pins.iter().cloned()); + } + assert_eq!( + planned, + stems(".facts.json"), + "manifest case names != *.facts.json under fixtures/verdict_renders" + ); + assert_eq!( + planned, + stems(".renders.json"), + "planned cases != *.renders.json goldens on disk (missing or orphaned golden)" + ); + + let mut divergences: Vec = Vec::new(); + for name in &planned { + let facts_text = read(&format!("{FIXDIR}/{name}.facts.json")); + let ours = serialize(&project(&facts_text)); + assert_eq!( + ours, + serialize(&project(&facts_text)), + "{name}: the rendered projection is not deterministic" + ); + for marker in ["\"subject\"", "'subject'"] { + assert!( + !ours.contains(marker), + "{name}: a rendered surface carries {marker} — the bridge's Finding has no \ + diagnostic subject and no output may invent one" + ); + } + let golden = read(&format!("{FIXDIR}/{name}.renders.json")); + if ours != golden { + let first = golden + .lines() + .zip(ours.lines()) + .enumerate() + .find(|(_, (a, b))| a != b); + let where_ = first.map_or_else( + || "the end — one document is longer".to_owned(), + |(i, (python, rust))| { + format!( + "line {}\n python = {python:?}\n rust = {rust:?}", + i + 1 + ) + }, + ); + divergences.push(format!("{name}: rendered bytes differ, first at {where_}")); + } + } + assert!( + divergences.is_empty(), + "{} rendered-surface divergence(s):\n{}", + divergences.len(), + divergences.join("\n") + ); + eprintln!( + "cp5.3 rendered surfaces: {} cases replayed byte-for-byte, {} BR-V9 rows pinned", + planned.len(), + pinned.len() + ); +} diff --git a/rust/crates/own-bridge/tests/verdicts.rs b/rust/crates/own-bridge/tests/verdicts.rs index 7adbda61..acd4109a 100644 --- a/rust/crates/own-bridge/tests/verdicts.rs +++ b/rust/crates/own-bridge/tests/verdicts.rs @@ -1,26 +1,30 @@ -//! The checkpoint-4 acceptance contract (#259): for every case of the Layer 3 +//! The checkpoint-5 acceptance contract (#259): for every case of the Layer 3 //! verdict fixture family, //! //! ```text //! facts.json → OwnIr (typed constructor, the tolerant entry) → own_bridge::check_facts -//! ≡ .verdicts.json on (file, line, column, code, component, -//! event, handler, kind, advisory, -//! severity, ignore_reason) +//! ≡ .verdicts.json on EVERY `Finding` member //! ``` //! //! The golden is the reference's COMPLETE `Finding` list (`ownlang/verdicts.py`, -//! regenerate: `python tests/test_verdict_fixtures.py --write`); this replay -//! declares the members it compares — identity, anchor, kind and tiering. The -//! two it does not, `message` and the evidence slices (`related`/`flow`), are -//! checkpoint 5's, and the golden carries them already so cp5 tightens this -//! comparison without regenerating anything. +//! regenerate: `python tests/test_verdict_fixtures.py --write`) and this replay +//! now compares all of it: cp4's identity, anchor, kind and tiering, plus the +//! synthesized `message` (BR-V4) and the ordered `related` / `flow` evidence +//! triples (BR-V5). **Not one golden was regenerated to get here** — they have +//! carried these three members since cp4, which is the whole reason the family +//! was built that way. //! -//! Refusals compare on the reference's error text — byte-exact for a -//! lowering-time refusal (vocabulary skew, an unknown resource kind), and up -//! to the `message=` member for the map-or-raise class (BR-V3), whose text -//! interpolates the core diagnostic's message that this core still carries as -//! its title. That normalization is the one declared cp4 comparison boundary -//! on a refusal, and it is applied to BOTH sides. +//! Refusals compare on the reference's error text **in full** — including the +//! `message=` member of the map-or-raise class (BR-V3), which interpolates the +//! core diagnostic's own message. cp4 had to cut the comparison there because +//! this core carried each code's title; cp5.2 gave `own_cfg::Diag` the +//! reference's message and removed the cut. No comparison boundary is left on +//! a refusal. +//! +//! That also makes the unported remainder of the core message layer a +//! tripwire rather than a blind spot: a code whose message `own-cfg` does not +//! carry still renders as its title, so the first golden that refuses on one +//! goes red here demanding the message, instead of agreeing with a title. //! //! Independently enforced here (not outsourced to Python): //! * ledger/tree equality — the swept corpora + the synthetic manifest cases @@ -35,9 +39,8 @@ #![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)] -use own_bridge::Finding; +use own_bridge::{Finding, Step}; use serde::Deserialize; -use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; const FIXDIR: &str = concat!( @@ -116,10 +119,11 @@ struct Golden { /// Every `ownir.Finding` member, strictly — a member added on the Python side /// goes red here until this replay is taught it (and decides whether to -/// compare it). +/// compare it). The evidence arrives as a fixed `(file, line, label)` TUPLE, +/// so a golden triple of the wrong arity fails to parse rather than comparing +/// as some shorter shape. #[derive(Deserialize)] #[serde(deny_unknown_fields)] -#[allow(dead_code)] // `message`/`related`/`flow` are parsed (shape-locked), compared at cp5 struct GoldenFinding { file: String, line: i64, @@ -131,67 +135,69 @@ struct GoldenFinding { kind: String, advisory: bool, severity: Option, - related: Vec>, - flow: Vec>, + related: Vec, + flow: Vec, ignore_reason: Option, column: Option, } -/// The checkpoint-4 comparison key. -type Key = ( - String, - i64, - Option, - String, - String, - String, - String, - String, - bool, - Option, - Option, -); +/// The checkpoint-5 comparison key: every member, in the reference's +/// declaration order. A named struct rather than a tuple — fourteen members is +/// past what the standard library derives for tuples, and a mislabelled field +/// in a divergence report is worse than a long type. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Key { + file: String, + line: i64, + column: Option, + code: String, + component: String, + event: String, + handler: String, + message: String, + kind: String, + advisory: bool, + severity: Option, + related: Vec, + flow: Vec, + ignore_reason: Option, +} fn key_of_golden(f: &GoldenFinding) -> Key { - ( - f.file.clone(), - f.line, - f.column, - f.code.clone(), - f.component.clone(), - f.event.clone(), - f.handler.clone(), - f.kind.clone(), - f.advisory, - f.severity.clone(), - f.ignore_reason.clone(), - ) + Key { + file: f.file.clone(), + line: f.line, + column: f.column, + code: f.code.clone(), + component: f.component.clone(), + event: f.event.clone(), + handler: f.handler.clone(), + message: f.message.clone(), + kind: f.kind.clone(), + advisory: f.advisory, + severity: f.severity.clone(), + related: f.related.clone(), + flow: f.flow.clone(), + ignore_reason: f.ignore_reason.clone(), + } } fn key_of_rust(f: &Finding) -> Key { - ( - f.file.clone(), - f.line, - f.column, - f.code.clone(), - f.component.clone(), - f.event.clone(), - f.handler.clone(), - f.kind.clone(), - f.advisory, - f.severity.clone(), - f.ignore_reason.clone(), - ) -} - -/// The refusal comparison class (see the module docs): the map-or-raise text -/// up to its `message=` member; every other refusal in full. -fn refusal_class(text: &str) -> String { - const MAP_OR_RAISE: &str = "internal: the core reported ["; - if text.starts_with(MAP_OR_RAISE) { - text.split(", message=").next().unwrap_or(text).to_owned() - } else { - text.to_owned() + Key { + file: f.file.clone(), + line: f.line, + column: f.column, + code: f.code.clone(), + component: f.component.clone(), + event: f.event.clone(), + handler: f.handler.clone(), + message: f.message.clone(), + kind: f.kind.clone(), + advisory: f.advisory, + severity: f.severity.clone(), + related: f.related.clone(), + flow: f.flow.clone(), + ignore_reason: f.ignore_reason.clone(), } } @@ -366,12 +372,13 @@ fn replay_case(name: &str, facts_path: &str) -> Result<(bool, usize), String> { ); match (first, golden.error, golden.findings) { (Err(err), Some(text), _) => { - let (got, want) = (refusal_class(&err.to_string()), refusal_class(&text)); + // Byte-exact on both sides, with no normalization (cp5.2). + let (got, want) = (err.to_string(), text); if got == want { Ok((true, 0)) } else { Err(format!( - "{name}: refusal class differs\n python = {want}\n rust = {got}" + "{name}: refusal text differs\n python = {want}\n rust = {got}" )) } } @@ -436,7 +443,7 @@ fn replays_every_case_to_its_golden() { failures.join("\n") ); eprintln!( - "cp4 verdict surface (identity/anchor/kind/tiering): {replayed} cases replayed \ + "cp5 verdict surface (every Finding member): {replayed} cases replayed \ ({refusals} refusals, {findings} findings), \ {} declared exclusions held", excluded.len() diff --git a/rust/crates/own-cfg/src/builder.rs b/rust/crates/own-cfg/src/builder.rs index b51404b5..479adc22 100644 --- a/rust/crates/own-cfg/src/builder.rs +++ b/rust/crates/own-cfg/src/builder.rs @@ -194,7 +194,14 @@ impl<'a> Builder<'a> { return Some(*id); } } - self.diag("OWN030", line); + // The reference interpolates the unresolved name, and the bridge's + // map-or-raise refusal quotes this message verbatim (BR-V3), so it is + // the one resolver text this core carries rather than a title. + self.diags.push(Diag::with_message( + "OWN030", + line, + format!("undefined name '{name}'"), + )); None } diff --git a/rust/crates/own-cfg/src/lib.rs b/rust/crates/own-cfg/src/lib.rs index e9a30321..1ab28af3 100644 --- a/rust/crates/own-cfg/src/lib.rs +++ b/rust/crates/own-cfg/src/lib.rs @@ -42,19 +42,42 @@ pub use own_syntax::ast::Effect; // as `Effect`, keeping `own-analysis` off a direct `own-syntax` production edge. pub use own_syntax::ast; -/// A flow-insensitive resolver diagnostic — its code and 1-based source line. +/// A flow-insensitive resolver diagnostic — its code, 1-based source line, and +/// the reference's human message **where this core carries it**. /// -/// The human message is deliberately absent (see the crate docs): this step's -/// oracle compares the CFG-JSON seam, which does not carry diagnostic text. +/// `message` is `None` for a diagnostic whose text has not been ported. That is +/// deliberately an absence rather than a stand-in: `own-analysis` substitutes +/// the code's title so a `None` still renders, while any surface that compares +/// message text goes RED on it instead of quietly agreeing with a title. The +/// `own-bridge` refusal replay is exactly such a surface (#259 cp5.2), so the +/// unported remainder is a tripwire, not a blind spot — the day a facts +/// document refuses on one of those codes, its golden demands the message. +/// +/// The ported set is the resolver's undefined-name message, which is the one +/// the bridge's map-or-raise text interpolates over the measured corpus. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Diag { pub code: &'static str, pub line: u32, + pub message: Option, } impl Diag { pub(crate) const fn new(code: &'static str, line: u32) -> Self { - Self { code, line } + Self { + code, + line, + message: None, + } + } + + /// A diagnostic carrying the reference's own message text. + pub(crate) const fn with_message(code: &'static str, line: u32, message: String) -> Self { + Self { + code, + line, + message: Some(message), + } } } diff --git a/rust/crates/own-shadow/src/engine.rs b/rust/crates/own-shadow/src/engine.rs index db44c1d2..72fc699e 100644 --- a/rust/crates/own-shadow/src/engine.rs +++ b/rust/crates/own-shadow/src/engine.rs @@ -14,21 +14,28 @@ //! //! ## The projection, and why the format needs one //! -//! Two of this engine's three layers emit the whole frozen surface — the -//! Layer 2 lowered document and the MOS summaries dump are byte-exact against -//! the reference's own goldens (#259 cp2 and cp3). The third does not: -//! `own_bridge::check_facts` is at the **#259 checkpoint-4 projection**, which -//! carries every `Finding` member except `message`, `related` and `flow` — -//! message synthesis (BR-V4) and the evidence slices are cp5 and are not -//! ported. +//! All three of this engine's layers now emit the whole frozen surface. The +//! Layer 2 lowered document and the MOS summaries dump have been byte-exact +//! against the reference's own goldens since #259 cp2 and cp3; the verdict +//! layer was the one **partial** projection — `own_bridge::check_facts` sat at +//! the #259 checkpoint-4 surface and carried every `Finding` member except +//! `message`, `related` and `flow` — and #259 cp5.1/5.2 ported those, so it is +//! `full` too. //! -//! A format without a projection field would leave a mid-migration port two -//! bad options: emit a short document and let a later comparison score the -//! absent members as agreement, or refuse a layer it can in fact mostly -//! produce. So the envelope carries `{"kind": "partial", "members": [...], -//! "reason": "..."}` and the port says exactly what it produced. This is the -//! cp4 discipline generalized — *a replay declares what it compares, and the -//! golden always carries everything*. +//! The field stays, and stays load-bearing. A format without it would leave a +//! mid-migration port two bad options: emit a short document and let a later +//! comparison score the absent members as agreement, or refuse a layer it can +//! in fact mostly produce. `{"kind": "partial", "members": [...], "reason": +//! "..."}` is how a port says exactly what it produced — the cp4 discipline +//! generalized, *a replay declares what it compares, and the golden always +//! carries everything*. That no layer needs it today is a fact about this +//! engine's progress, not a reason to drop the field; the census fragment is +//! where that fact is counted. +//! +//! **This is not the verdict layer entering shadow mode.** The reducer still +//! REFUSES it and records the refusal in every reduction; that stays until +//! #260's acceptance, after row 4b. What changed is only this engine's honest +//! declaration of what it puts in the envelope. //! //! ## The typed door is upstream of every layer //! @@ -46,27 +53,6 @@ use own_ir::OwnIr; use crate::artifact::{ENGINE_RUST, LAYER_ORDER, STATUS_PRODUCED, STATUS_REFUSED}; use crate::json::{parse, Json}; -/// The `Finding` members `own_bridge::check_facts` carries at the #259 -/// checkpoint-4 surface, in `ownir.Finding`'s declaration order. `message`, -/// `related` and `flow` are absent and the projection says so. -const VERDICT_MEMBERS: [&str; 11] = [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column", -]; - -const VERDICT_PROJECTION_REASON: &str = "own_bridge::check_facts is at the #259 checkpoint-4 \ - surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 \ - and are not ported, so this engine does not emit them rather than emitting them empty"; - fn object(entries: Vec<(&str, Json)>) -> Json { Json::Object( entries @@ -80,6 +66,12 @@ fn full_projection() -> Json { object(vec![("kind", Json::Str("full".to_owned()))]) } +/// The format's partial branch. No layer of THIS engine needs it today (the +/// verdict layer was the last one, and #259 cp5.1/5.2 completed it), and it is +/// kept because the field is the format's, not this engine's progress report: +/// the reference emits partials, and the next port to land mid-surface will. +/// Pinned by a unit test so an unused-but-contractual shape cannot rot. +#[cfg_attr(not(test), allow(dead_code))] fn partial_projection(members: &[&str], reason: &str) -> Json { object(vec![ ("kind", Json::Str("partial".to_owned())), @@ -188,10 +180,26 @@ fn summaries_layer(facts: &OwnIr) -> Result { /// and `summaries` can fail on *serialization*, which is an internal defect /// rather than a disagreement with the reference, so only they return a /// `Result`.) +/// One `[file, line, label]` evidence triple, the shape the Layer 3 surface +/// serializes (`ownlang/verdicts.py`). +fn steps(slice_: &[own_bridge::Step]) -> Json { + Json::Array( + slice_ + .iter() + .map(|(file, line, label)| { + Json::Array(vec![ + Json::Str(file.clone()), + Json::Int(*line), + Json::Str(label.clone()), + ]) + }) + .collect(), + ) +} + fn verdicts_layer(facts: &OwnIr) -> Json { - let projection = partial_projection(&VERDICT_MEMBERS, VERDICT_PROJECTION_REASON); - // `VERDICTS_VERSION` is the reference's, and this engine replays that - // surface — the projection, not the version, is what differs. + // Every `Finding` member since #259 cp5.1/5.2 — no projection to declare. + let projection = full_projection(); let version = Json::Int(1); match own_bridge::check_facts(facts) { Ok(findings) => { @@ -205,9 +213,12 @@ fn verdicts_layer(facts: &OwnIr) -> Json { ("component", Json::Str(f.component.clone())), ("event", Json::Str(f.event.clone())), ("handler", Json::Str(f.handler.clone())), + ("message", Json::Str(f.message.clone())), ("kind", Json::Str(f.kind.clone())), ("advisory", Json::Bool(f.advisory)), ("severity", opt_str(f.severity.as_deref())), + ("related", steps(&f.related)), + ("flow", steps(&f.flow)), ("ignore_reason", opt_str(f.ignore_reason.as_deref())), ("column", f.column.map_or(Json::Null, Json::Int)), ]) @@ -226,3 +237,34 @@ fn verdicts_layer(facts: &OwnIr) -> Json { fn opt_str(value: Option<&str>) -> Json { value.map_or(Json::Null, |s| Json::Str(s.to_owned())) } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::{full_projection, partial_projection, Json}; + + /// The two projection shapes the artifact format declares. `partial` has no + /// caller in this engine any more — every layer emits its whole surface — + /// so without this its shape would be unchecked the day someone needs it. + #[test] + fn the_two_projection_shapes_are_pinned() { + assert_eq!( + full_projection(), + Json::Object(vec![("kind".to_owned(), Json::Str("full".to_owned()))]) + ); + assert_eq!( + partial_projection(&["line", "code"], "why"), + Json::Object(vec![ + ("kind".to_owned(), Json::Str("partial".to_owned())), + ( + "members".to_owned(), + Json::Array(vec![ + Json::Str("line".to_owned()), + Json::Str("code".to_owned()) + ]) + ), + ("reason".to_owned(), Json::Str("why".to_owned())), + ]) + ); + } +} diff --git a/rust/crates/own-shadow/tests/engine.rs b/rust/crates/own-shadow/tests/engine.rs index 064b7857..af0ef06a 100644 --- a/rust/crates/own-shadow/tests/engine.rs +++ b/rust/crates/own-shadow/tests/engine.rs @@ -174,40 +174,70 @@ fn this_engine_reproduces_its_committed_capture() { ); } +/// The complete Layer 3 record — `ownir.Finding`'s members in declaration +/// order. The authority is the reference (`ownlang/verdicts.py`) and the place +/// it is enforced against it is `own-bridge/tests/verdicts.rs`, whose golden +/// parses with `deny_unknown_fields`; this list exists so a `full` claim here +/// means the same thing that replay means by it. +const VERDICT_SURFACE_MEMBERS: [&str; 14] = [ + "file", + "line", + "code", + "component", + "event", + "handler", + "message", + "kind", + "advisory", + "severity", + "related", + "flow", + "ignore_reason", + "column", +]; + #[test] -fn a_partial_projection_names_exactly_the_members_it_carries() { +fn a_projection_names_exactly_the_members_it_carries() { if stand_down_while_writing() { return; } - // The one way a projection can lie: claim members the documents do not - // have, or carry members it did not claim. Without this the field is prose - // and a later comparison would trust it. + // The two ways a projection can lie, and BOTH are live now. A `partial` can + // claim members its documents do not have, or carry ones it did not claim. + // A `full` can be declared over a SHORT document — which became the + // reachable lie the day the verdict layer stopped being partial (#259 + // cp5.1/5.2), and which the partial-only version of this test could not + // see. for (name, facts_path) in artifacts() { let ours = capture(&read(&facts_path)).expect("capture"); for layer in ours.get("layers").and_then(Json::as_array).expect("layers") { let projection = layer.get("projection").expect("projection"); - if projection.get("kind").and_then(Json::as_str) != Some("partial") { - continue; - } - let claimed: BTreeSet<&str> = projection - .get("members") - .and_then(Json::as_array) - .expect("members") - .iter() - .filter_map(Json::as_str) - .collect(); - assert!( - !claimed.is_empty(), - "{name}: a partial projection names nothing" - ); + let kind = projection.get("kind").and_then(Json::as_str); let Some(document) = layer.get("document") else { continue; // a refused layer carries no records to check }; - // The only partial surface today is the verdict list; its records - // are the things whose members the projection describes. + // The verdict list is the only layer whose records this test can + // describe; the other two surfaces are documents of their own shape. let Some(records) = document.get("findings").and_then(Json::as_array) else { continue; }; + let claimed: BTreeSet<&str> = match kind { + Some("partial") => { + let claimed: BTreeSet<&str> = projection + .get("members") + .and_then(Json::as_array) + .expect("members") + .iter() + .filter_map(Json::as_str) + .collect(); + assert!( + !claimed.is_empty(), + "{name}: a partial projection names nothing" + ); + claimed + } + Some("full") => VERDICT_SURFACE_MEMBERS.into_iter().collect(), + other => panic!("{name}: unknown projection kind {other:?}"), + }; for record in records { let actual: BTreeSet<&str> = record.keys().into_iter().collect(); assert_eq!( diff --git a/scripts/render_checkpoint_status.py b/scripts/render_checkpoint_status.py index be5272bc..b8c8db02 100644 --- a/scripts/render_checkpoint_status.py +++ b/scripts/render_checkpoint_status.py @@ -5,13 +5,25 @@ say WHAT a checkpoint proves and link here; the measured numbers live only in these generated fragments, computed from the evidence — never typed: -* `docs/generated/p022-cp4-census.md` — the verdict ledger census, from - `tests/verdict_census.compute_verdict_census()` (the same interpretation the - fixture harness uses). +* `docs/generated/p022-cp4-census.md` — the Layer 3 census: the verdict ledger + from `tests/verdict_census.compute_verdict_census()` and the rendered-surface + family from `tests/verdict_render_census.compute_render_census()` (in both + cases the same interpretation the fixture harnesses use). The filename is + checkpoint 4's, because that is where the fragment was introduced and two + notes link it; what it DESCRIBES is the current comparison surface, which the + document says in its own first paragraph. +* `docs/generated/p022-cp5-inventory.md` — the checkpoint-5 SURFACE inventory, + from `tests/verdict_surface_inventory.compute_surface_inventory()`: which + BR-V4 wording branch, BR-V5 evidence family and BR-V9 rendered-surface rule + the frozen goldens already reach, and which are not reached at all. The + census counts the ledger; this one says what the ledger covers. * `docs/generated/p022-cp4-mutations.md` — the recorded mutation campaign, from `docs/evidence/p022-cp4-mutations.json` and its `.result.json`, through `scripts/mutate_campaign.summarize()` (the same interpretation the runner prints). +* `docs/generated/p022-cp5-mutations.md` — checkpoint 5's recorded mutation + campaigns, one section per sub-checkpoint, through the same + `summarize()` as every other campaign in the tree. * `docs/generated/p022-shadow-census.md` — the step-7a (#260/#269) shadow-mode INFRASTRUCTURE census, from `tests/shadow_census.compute_shadow_census()` over the committed @@ -59,10 +71,23 @@ ) from shadow_census import ShadowCensus, ShadowCensusError, compute_shadow_census # noqa: E402 from verdict_census import Census, CensusError, compute_verdict_census # noqa: E402 +from verdict_render_census import ( # noqa: E402 + RenderCensus, + RenderCensusError, + compute_render_census, +) +from verdict_surface_inventory import ( # noqa: E402 + Coverage, + InventoryError, + SurfaceInventory, + compute_surface_inventory, +) GENERATED = os.path.join(ROOT, "docs", "generated") EVIDENCE = os.path.join(ROOT, "docs", "evidence") CENSUS_MD = "p022-cp4-census.md" +INVENTORY_MD = "p022-cp5-inventory.md" +CP5_MUTATIONS_MD = "p022-cp5-mutations.md" MUTATIONS_MD = "p022-cp4-mutations.md" SHADOW_CENSUS_MD = "p022-shadow-census.md" SHADOW_MUTATIONS_MD = "p022-shadow-mutations.md" @@ -76,6 +101,14 @@ ("checkpoint 3 — the AnalysisTrace and stable-ID normalization", "p022-shadow-cp3"), ("checkpoint 4 — first-divergence reduction", "p022-shadow-cp4"), ) +# One campaign per cp5 sub-checkpoint, for the same reason the shadow slice has +# one per checkpoint: a campaign stays frozen at what it measured, so a later +# sub-checkpoint cannot quietly restate an earlier one's numbers. +CP5_CAMPAIGNS = ( + ("checkpoint 5.1 — the message matrix and the evidence slices", "p022-cp5-1"), + ("checkpoint 5.2 — the refusal text and the core message it quotes", "p022-cp5-2"), + ("checkpoint 5.3 — the rendered surfaces", "p022-cp5-3"), +) SELF = "scripts/render_checkpoint_status.py" @@ -91,7 +124,7 @@ def _rel(path: str) -> str: # --- census --------------------------------------------------------------- -def render_census(c: Census) -> str: +def render_census(c: Census, r: RenderCensus | None) -> str: rows: list[tuple[str, str]] = [ ("goldens — Python's complete truth, one per planned case", str(c.goldens))] for origin, n in c.by_origin: @@ -111,19 +144,28 @@ def render_census(c: Census) -> str: what = f"… refused by `check_facts` with an error containing `{contains}`" rows.append((what, str(n))) rows += [ - ("replayed by Rust at the cp4 surface (goldens minus exclusions)", str(c.replayed)), - ("… reference refusals among them (compared by refusal class)", - str(c.replayed_refusals)), - ("… findings among them (compared on the cp4 members)", str(c.replayed_findings)), + ("replayed by Rust (goldens minus exclusions)", str(c.replayed)), + ("… reference refusals among them (compared in full)", str(c.replayed_refusals)), + ("… findings among them (compared on every `Finding` member)", + str(c.replayed_findings)), ] width = max(len(k) for k, _ in rows) lines = [ - _header("tests/fixtures/verdicts/manifest.json and the *.verdicts.json goldens"), - "# P-022 checkpoint 4 — measured census", + _header("tests/fixtures/verdicts/ and tests/fixtures/verdict_renders/ " + "(manifests + goldens)"), + "# P-022 #259 — the Layer 3 measured census", + "", + "Computed by `tests/verdict_census.py` and `tests/verdict_render_census.py` (the " + "interpretations the two fixture harnesses verify against the Python projections) " + "over the frozen ledgers; the Rust halves are " + "`rust/crates/own-bridge/tests/verdicts.rs` and `.../tests/renders.rs`.", "", - "Computed by `tests/verdict_census.py` (the interpretation " - "`tests/test_verdict_fixtures.py` verifies against the Python projection) over the " - "frozen Layer 3 ledger; the Rust half is `rust/crates/own-bridge/tests/verdicts.rs`.", + "**The surface this describes is checkpoint 5's**: the verdict replay compares " + "EVERY `Finding` member (`message`, `related` and `flow` included) and every " + "refusal in full, and the rendered-surface replay compares bytes. At checkpoint 4 " + "the same ledger was compared on identity, anchor, kind and tiering only, and " + "refusals up to their `message=` member; the counts below are the ledger's either " + "way, which is why one fragment serves both and says which surface it means.", "", f"| {'measure'.ljust(width)} | value |", f"|{'-' * (width + 2)}|------:|", @@ -133,13 +175,115 @@ def render_census(c: Census) -> str: "", "The differential counts over the replayed set — Python-only, Rust-only, changed, " "ordering-only, unexplained — are asserted, not measured here: the Rust replay " - "compares every replayed case's full ordered verdict list (or its refusal class) " - "against the golden on the cp4 members, collects every divergence without " - "fail-fast, and fails if one exists. A green " - "`cargo test -p own-bridge --test verdicts` is 0 / 0 / 0 / 0 / 0 by construction; " - "a non-zero count is a red build.", + "compares every replayed case's full ordered verdict list (or its refusal text) " + "against the golden on every member, collects every divergence without fail-fast, " + "and fails if one exists. A green `cargo test -p own-bridge --test verdicts` is " + "0 / 0 / 0 / 0 / 0 by construction; a non-zero count is a red build.", "", + "## The rendered surfaces (BR-V9)", + "", + "A second family, and a different kind of comparison: its replay compares the " + "**bytes**, because SARIF key order is part of this surface. Cases are listed, " + "never swept — one exists to exercise a BR-V9 rule, and which rows each pins is " + "the join the [surface inventory](" + INVENTORY_MD + ") reports on.", + "", + ] + if r is None: + lines += ["The family could not be counted (see the gate's problems).", ""] + return "\n".join(lines) + render_rows = [ + ("cases — one per BR-V9 rule group, listed exhaustively in the manifest", + str(r.cases)), + ("… whose golden is a bridge refusal (nothing to render)", str(r.refusals)), + ("rendered lines compared byte-for-byte (4 formats, 2 host severities)", + str(r.rendered_lines)), + ("SARIF results compared byte-for-byte (both host severities)", + str(r.sarif_results)), + ("BR-V9 ledger rows pinned by at least one case", str(r.pinned_rows)), ] + width = max(len(k) for k, _ in render_rows) + lines += [f"| {'measure'.ljust(width)} | value |", f"|{'-' * (width + 2)}|------:|"] + lines += [f"| {k.ljust(width)} | {v} |" for k, v in render_rows] + lines.append("") + return "\n".join(lines) + + +# --- checkpoint 5: the surface inventory ---------------------------------- + + +def _coverage_table(rows: tuple[Coverage, ...]) -> list[str]: + """One ledger as a table: id, what it is, and the two measured counts. A row + at zero over the replayed set is a gap, marked so a reader does not have to + compare two numbers to find it.""" + out = ["| ledger row | surface | what it is | all goldens | replayed |", + "|---|---|---|---:|---:|"] + for c in rows: + what = c.what + if c.replayed == 0: + what += f" — **not replayed**: {c.note}" if c.note else " — **GAP: no control**" + out.append(f"| `{c.id}` | {c.detail} | {what} | {c.total} | {c.replayed} |") + out.append("") + return out + + +def render_inventory(inv: SurfaceInventory) -> str: + """The cp5 surface ledger. Every count is matched out of the committed + goldens by `tests/verdict_surface_inventory.py`; a finding or slice the + ledger cannot place fails the gate rather than being rounded away.""" + lines = [ + _header("tests/fixtures/verdicts/*.verdicts.json through " + "tests/verdict_surface_inventory.py"), + "# P-022 checkpoint 5 — surface inventory (what the frozen goldens reach)", + "", + "Checkpoint 4 proved identity, anchor, kind and tiering over the replayed set " + "([census](" + CENSUS_MD + ")). Checkpoint 5 proves the three surfaces cp4 " + "carried without comparing: the **messages** (BR-V4), the **evidence slices** " + "(BR-V5) and the **rendered surfaces** (BR-V9). This fragment is the " + "completeness ledger for those three: every branch read off `ownlang/ownir.py`, " + "matched against the committed goldens.", + "", + "`all goldens` counts Python's complete truth; `replayed` counts only the cases " + "the Rust replay runs (the ledger's `rust_replay_excluded` entries removed). A " + "row whose **replayed** count is zero is a branch the golden corpus does not " + "prove; each such row carries its **disposition** — what pins the branch instead, " + "and why no facts document can reach it. A zero row with no disposition reads " + "`GAP: no control`, which is a missing control, not a passing one.", + "", + "## BR-V4 — message synthesis, by who owns the string", + "", + "`bridge` — synthesized by `check_facts` from the handle record; `core-analysis` " + "— the `message` property of `ownlang/di.py` / `ownlang/effects.py`'s own " + "finding; `core-diagnostic` — the core `Diagnostic.message`, interpolated " + "verbatim; `bridge-protocol` — the OBL family, which is #259 row 4b and outside " + "cp5.", + "", + ] + lines += _coverage_table(inv.messages) + lines += ["### Wording tails", "", + "Each is its own degradation rule inside an analysis message — the tail is " + "dropped, not blanked, when its location is unknown.", ""] + lines += _coverage_table(inv.tails) + lines += ["## BR-V5 — evidence slices", "", + "One row per `related`/`flow` family; a slice matching no family (or two) " + "fails the gate.", ""] + lines += _coverage_table(inv.slices) + lines += ["### Degradations", "", + "The rules that produce an EMPTY slice: a step whose line is unknown is " + "omitted, and a slice left shorter than two steps is dropped. Counted " + "separately, because a rule only ever seen firing positively has no " + "negative control.", ""] + lines += _coverage_table(inv.degradations) + lines += ["## BR-V9 — rendered surfaces", ""] + if inv.render_family_exists: + lines += ["Coverage is matched out of the `tests/fixtures/verdict_renders/` " + "family's `pins` ledger.", ""] + else: + lines += ["**No fixture family exists yet.** `render_finding` and `build_sarif` " + "on the bridge path have no golden of their own: checkpoint 5.3 builds " + "`tests/fixtures/verdict_renders/`, and every row below reads zero " + "until it does. The rows are declared here so the gap is a ledger " + "entry rather than an omission.", ""] + lines += _coverage_table(inv.renders) return "\n".join(lines) @@ -235,25 +379,32 @@ def _mutation_section(heading: str, definition: Definition | None, result: Resul # --- step 7a: the shadow-mode infrastructure slice ------------------------ -def _shadow_paths(campaign: str) -> tuple[str, str]: +def render_shadow_mutations() -> tuple[str, list[str]]: + """The slice's four campaigns, one document, the same interpreter as cp4's.""" + return render_campaign_set( + "# P-022 step 7a — shadow-mode infrastructure: mutation campaigns", + "Every mutation edits a **production** surface (P-022 discipline 2) and every " + "declared layer runs for every mutation (discipline 3: no fail-fast). Each " + "campaign stays frozen at what it measured; the counts below are derived from " + "the recorded runs by `scripts/mutate_campaign.summarize()`, never typed.", + SHADOW_CAMPAIGNS) + + +def _campaign_paths(campaign: str) -> tuple[str, str]: return (os.path.join(EVIDENCE, f"{campaign}.json"), os.path.join(EVIDENCE, f"{campaign}.result.json")) -def render_shadow_mutations() -> tuple[str, list[str]]: - """The slice's four campaigns, one document, the same interpreter as cp4's.""" - sources = ", ".join(_rel(_shadow_paths(c)[0]) for _, c in SHADOW_CAMPAIGNS) - parts = [_header(f"{sources} and their .result.json"), - "# P-022 step 7a — shadow-mode infrastructure: mutation campaigns", - "", - "Every mutation edits a **production** surface (P-022 discipline 2) and every " - "declared layer runs for every mutation (discipline 3: no fail-fast). Each " - "campaign stays frozen at what it measured; the counts below are derived from " - "the recorded runs by `scripts/mutate_campaign.summarize()`, never typed.", - ""] +def render_campaign_set(heading: str, blurb: str, campaigns: tuple[tuple[str, str], ...], + ) -> tuple[str, list[str]]: + """A set of campaigns as one document, through the single interpreter every + campaign in the tree shares. Two readings of one run is how two documents + come to disagree about it.""" + sources = ", ".join(_rel(_campaign_paths(c)[0]) for _, c in campaigns) + parts = [_header(f"{sources} and their .result.json"), heading, "", blurb, ""] problems: list[str] = [] - for title, campaign in SHADOW_CAMPAIGNS: - definition_path, result_path = _shadow_paths(campaign) + for title, campaign in campaigns: + definition_path, result_path = _campaign_paths(campaign) definition, result, load_problems = _load_campaign(definition_path, result_path) problems.extend(f"{campaign}: {p}" for p in load_problems) summary = summarize(definition, result) if definition and result else None @@ -323,11 +474,20 @@ def render_shadow_census(c: ShadowCensus) -> str: |---|---|---|---|---| {engine_rows} -The port's `partial` layers are its verdict surface: `own_bridge::check_facts` -is at the #259 checkpoint-4 projection, which carries every `Finding` member -except `message`, `related` and `flow`. It says so in the artifact rather than -emitting a short document a later comparison would score as agreement, and a -test asserts the claim matches the records byte for byte. +The port's `partial` column read non-zero until #259 cp5.1/5.2: its verdict +surface sat at the checkpoint-4 projection, carrying every `Finding` member +except `message`, `related` and `flow`, and said so in the artifact rather than +emitting a short document a later comparison would score as agreement. Those +members are ported, so the layer is `full` and no partial projection remains — +a fact about this port's progress, not a reason to drop the field. The check +moved with it: it now asserts a `full` claim against the complete Layer 3 +record too, because a `full` declared over a short document is the over-claim +that became reachable the moment nothing was partial. + +**Still not shadow mode, and still not the verdict layer entering it.** The +reducer REFUSES the verdict layer and records the refusal in every reduction; +what changed above is one engine's declaration of what it puts in the +envelope. **Layer envelopes where the two engines' status differs** — structural accounting, not a content comparison, and every one of them a boundary the port @@ -440,10 +600,19 @@ def fragments() -> tuple[dict[str, str], list[str]]: gate's, not the fragment's: it never changes the rendered text.""" out: dict[str, str] = {} problems: list[str] = [] + renders: RenderCensus | None = None + try: + renders = compute_render_census() + except RenderCensusError as e: + problems.extend(f"rendered-surface census: {p}" for p in e.problems) try: - out[CENSUS_MD] = render_census(compute_verdict_census()) + out[CENSUS_MD] = render_census(compute_verdict_census(), renders) except CensusError as e: problems.extend(f"verdict census: {p}" for p in e.problems) + try: + out[INVENTORY_MD] = render_inventory(compute_surface_inventory()) + except InventoryError as e: + problems.extend(f"cp5 surface inventory: {p}" for p in e.problems) definition, result, campaign_problems = _load_campaign() problems.extend(campaign_problems) summary = summarize(definition, result) if definition and result else None @@ -458,6 +627,16 @@ def fragments() -> tuple[dict[str, str], list[str]]: shadow, shadow_problems = render_shadow_mutations() out[SHADOW_MUTATIONS_MD] = shadow problems.extend(f"mutation campaign {p}" for p in shadow_problems) + cp5, cp5_problems = render_campaign_set( + "# P-022 checkpoint 5 — mutation campaigns", + "One campaign per sub-checkpoint, each frozen at what it measured. Every " + "mutation edits a **production** surface (P-022 discipline 2) and every " + "workspace member runs for every mutation (discipline 3: no fail-fast); the " + "counts are derived from the recorded runs by " + "`scripts/mutate_campaign.summarize()`, never typed.", + CP5_CAMPAIGNS) + out[CP5_MUTATIONS_MD] = cp5 + problems.extend(f"mutation campaign {p}" for p in cp5_problems) return out, problems @@ -501,8 +680,9 @@ def main(argv: list[str]) -> int: if problems: return 1 if argv: - print(f"checkpoint status fragments OK: {CENSUS_MD}, {MUTATIONS_MD}, " - f"{SHADOW_CENSUS_MD}, {SHADOW_MUTATIONS_MD} in sync with the evidence") + print(f"checkpoint status fragments OK: {CENSUS_MD}, {INVENTORY_MD}, " + f"{MUTATIONS_MD}, {CP5_MUTATIONS_MD}, {SHADOW_CENSUS_MD}, " + f"{SHADOW_MUTATIONS_MD} in sync with the evidence") return 0 diff --git a/spec/Bridge.md b/spec/Bridge.md index f42437c0..de6639be 100644 --- a/spec/Bridge.md +++ b/spec/Bridge.md @@ -366,29 +366,47 @@ committed regeneration path and a zero-Python steady state: (IR4-everywhere fail-loud), `tolerant_unknown_kind` is now one of those shared cases — its `Rejected` golden pins the identical error text on both sides — so there are **no `rust_replay: false` snapshots left**. Layer 1 - landed in `own-ir` (#259 cp1: 216 controls, 0/0/0); Layer 3 is built at the - checkpoint-4 surface (below); #259 as a whole remains open on cp5. -- **Layer 3 — final normalized diagnostics.** The findings list (and its - SARIF/github/msbuild renderings) per facts fixture, byte-exact — the outer - contract. **Built** (#259 cp4): `ownlang/verdicts.py` is the authoritative - Python emitter (`VERDICTS_VERSION` keys the surface; its docstring freezes - the normalization decisions — every `Finding` member in declaration order, - the bridge's own ordering, `[file, line, label]` evidence triples, a - refusal as `{"error": …}`), `tests/fixtures/verdicts/` holds the goldens - under the frozen `manifest.json` ledger, and `tests/test_verdict_fixtures.py` - is the verify/`--write` harness. The cases are the swept `ownir`/`lowered`/ - `summaries` corpora plus synthetic verdict controls, all through the - **tolerant door** (`check_facts` on the loaded dict, as `test_ownir.py` - does). On the Rust side `own_bridge::check_facts` replays every case - (`own-bridge/tests/verdicts.rs`); at cp4 the replay compares **identity, - anchor, kind and tiering** — every member but `message`, `related` and - `flow`, which the goldens already carry and cp5 compares. The manifest's - `rust_replay_excluded` ledger names the documents the Rust core **refuses - by a declared boundary** — a protocol-bearing document (OBL analysis not - ported), a coordinate outside the core's `u32` line domain, a shape the - typed Rust door rejects before the bridge runs (OD-1) — each with its - reason and an expectation the replay executes, so an exclusion cannot rot. - The `summaries` dump (INF-R1) covers the MOS sub-surface. + landed in `own-ir` (#259 cp1: 216 controls, 0/0/0); Layer 3 is built and fully + compared (below); #259 as a whole remains open on **row 4b** (the + obligation-protocol analysis) and the coordinate-domain decision. +- **Layer 3 — final normalized diagnostics.** The findings list per facts + fixture, and its SARIF/github/msbuild renderings — the outer contract, in + **two families**. Built at #259 cp4, fully compared at cp5: + + - *the verdict list*: `ownlang/verdicts.py` is the authoritative Python + emitter (`VERDICTS_VERSION` keys the surface; its docstring freezes the + normalization — every `Finding` member in declaration order, the bridge's + own ordering, `[file, line, label]` evidence triples, a refusal as + `{"error": …}`), `tests/fixtures/verdicts/` holds the goldens under the + frozen `manifest.json` ledger, and `tests/test_verdict_fixtures.py` is the + verify/`--write` harness. The cases are the swept `ownir`/`lowered`/ + `summaries` corpora plus synthetic verdict controls, all through the + **tolerant door** (`check_facts` on the loaded dict, as `test_ownir.py` + does). `own_bridge::check_facts` replays every case + (`own-bridge/tests/verdicts.rs`) and compares **every `Finding` member** — + identity, anchor, kind, tiering, the BR-V4 message and the BR-V5 + `related`/`flow` slices — plus every refusal **in full**, with no + normalization on either side. No golden was regenerated to reach that: + they have carried all three of the late members since cp4. + - *the rendered surfaces* (BR-V9): `ownlang/renders.py` is the authoritative + emitter (`RENDERS_VERSION` keys the surface; every format at both host + severities, plus one format `render_finding` does not know so the fallback + is rendered rather than assumed), `tests/fixtures/verdict_renders/` holds + the goldens under their own frozen ledger — cases are **listed, never + swept**, each naming the BR-V9 rules it is the control for — + `tests/test_verdict_render_fixtures.py` is the harness, and + `own-bridge/tests/renders.rs` replays them **byte for byte**, because + SARIF key order is part of this surface. + + The manifest's `rust_replay_excluded` ledger names the documents the Rust + core **refuses by a declared boundary** — a protocol-bearing document (OBL + analysis not ported), a coordinate outside the core's `u32` line domain, a + shape the typed Rust door rejects before the bridge runs (OD-1) — each with + its reason and an expectation the replay executes, so an exclusion cannot + rot. The `summaries` dump (INF-R1) covers the MOS sub-surface. Which BR-V4 + wording, BR-V5 slice family and BR-V9 rule the corpus reaches — and the + recorded disposition of every one it does not — is the generated ledger + [`p022-cp5-inventory.md`](../docs/generated/p022-cp5-inventory.md). **Composing the three layers: the reproduction artifact.** The three layers above are each frozen on their own, and step 7a (#260/#269) needs them diff --git a/spec/BridgeBehaviorMatrix.md b/spec/BridgeBehaviorMatrix.md index 30903142..cc4ffca9 100644 --- a/spec/BridgeBehaviorMatrix.md +++ b/spec/BridgeBehaviorMatrix.md @@ -7,7 +7,10 @@ > this ledger was generated on — they drift with edits; the *family names* are > the stable identity), and the parity-fixture **layer** (§6 of Bridge.md: > L1 = validation, L2 = normalized lowered representation, L3 = normalized -> diagnostics, S = the `summaries` dump) the Rust port must replay it at. +> diagnostics, S = the `summaries` dump) the Rust port must replay it at. A +> layer marked **L3 ✅** is one whose substance the Rust replay now compares in +> full rather than carries — the BR-V4 wording matrix and the BR-V9 renderings, +> which #259 cp4 left deferred and cp5 proved. > **No family may be silently omitted here**; a new `test_ownir.py` family > without a row (or vice-versa) is a red build in spirit — reviewers enforce > it until a generated cross-check exists (see OD-7). @@ -84,20 +87,20 @@ | publisher provenance: `returned_fresh` silent; unknown value keeps warning; beats DI hop | routing R4 | BR-L1 | L208–L230 (4) | L3 | | suppression: non-empty reason suppresses (still minted); empty does not; SARIF `suppressions` | BR-V6 | — | L271–L293 (4) | L3 | | subscribe tiering (self/injected/static) | routing R2 + BR-V4 | — | L328–L341 (3) | L3 | -| per-kind findings: timer / disposable field / ignored subscribe / pool / local-disposable (location, wording, tag, released-twin silence) | BR-V4 | message matrix | L523–L617 (11) | L3 | -| flow-local OWN001 wording split (`ever_released`), component naming, kind tags | BR-V4 | — | L643–L671 (6) | L3 | +| per-kind findings: timer / disposable field / ignored subscribe / pool / local-disposable (location, wording, tag, released-twin silence) | BR-V4 | message matrix | L523–L617 (11) | L3 ✅ | +| flow-local OWN001 wording split (`ever_released`), component naming, kind tags | BR-V4 | — | L643–L671 (6) | L3 ✅ | | exception-edge dedup (one OWN001 per acquire across exits); nested-throw recall; finally+switch; pool labelling; while fixpoint verdicts | BR-V7 + core lowering | — | L691–L774 (5) | L3 | | DI bridge findings: severity, anchors (DI004 call site / DI005 store site + registration `related`), flows, not-double-reported | BR-P1, BR-V5 | — | L855–L1180 (17) | L3 | | OWN050 advisory: location/message/kind; coexists with real OWN001 | `_unresolved_findings` | BR-V1/V6 | L1262–L1275 (3) | L3 | | OWN014 captures: static source, released silent, injected conservative, lambda note; DI-sourced escalation + proven-safe transient + additive fallback; escape flow slice | routing R3/R5, BR-V4/V5 | — | L1306–L1408 (8) | L3 | | flow evidence slices: handoff OWN002 2-step; consume-param OWN001 maps (no "cannot map back"); timer with incidental source keeps its path; OWN025 Rent→view flow | BR-V3/V5 | — | L1455–L1482 (3), L3004 | L3 | -| SARIF projection: envelope, driver, rules, results, levels, `--severity warning`, empty run, oracle round-trip | `build_sarif` | BR-V9 | L3081–L3139 (11) | L3 | +| SARIF projection: envelope, driver, rules, results, levels, `--severity warning`, empty run, oracle round-trip | `build_sarif` | BR-V9 | L3081–L3139 (11) | L3 ✅ | ## (f) Rendering / CLI surfaces | Behavior | Source | Rule | Pinned by | Layer | |---|---|---|---|---| -| `github` / `msbuild` / fallback-human renders; escaping; severity pass-through; msbuild default stays `error` | `render_finding` | BR-V9 | L3038–L3074 (8) | L3 | +| `github` / `msbuild` / fallback-human renders; escaping; severity pass-through; msbuild default stays `error` | `render_finding` | BR-V9 | L3038–L3074 (8) | L3 ✅ | ## Open decisions cross-reference @@ -126,17 +129,21 @@ With OD-2/#294 resolved (IR4-everywhere fail-loud), `tolerant_unknown_kind` is now one of those shared cases — its `Rejected` golden pins the identical error text on both sides — so no Python-only cases remain. The per-case coverage of the fixture family is listed in the #259 foundation PR. Layer 1 landed in -`own-ir` (#259 cp1). **Layer 3 is built** (#259 cp4): `ownlang/verdicts.py` + +`own-ir` (#259 cp1). **Layer 3 is built and fully compared** (#259 cp4 built +it, cp5 finished the comparison), in two families: `ownlang/verdicts.py` + `tests/fixtures/verdicts/` + `tests/test_verdict_fixtures.py`, replayed by -`rust/crates/own-bridge/tests/verdicts.rs` through `own_bridge::check_facts` -— so every **L3** row above has a directly-pinned end-to-end surface. At cp4 -the replay compares each finding's identity, anchor, kind and tiering; the -rows whose substance is message text or an evidence slice (the BR-V4 -wording matrix, the `flow`/`related` steps, the BR-V9 renderings) are -carried by the goldens and compared at cp5. Two row families are outside -the replayed set by declaration, recorded in the manifest's -`rust_replay_excluded` ledger with an executable expectation: the +`rust/crates/own-bridge/tests/verdicts.rs` through `own_bridge::check_facts` on +**every `Finding` member** — identity, anchor, kind, tiering, the BR-V4 message +and the BR-V5 `related`/`flow` slices — and every refusal in full; and +`ownlang/renders.py` + `tests/fixtures/verdict_renders/` + +`tests/test_verdict_render_fixtures.py`, replayed **byte for byte** by +`.../tests/renders.rs` for the BR-V9 surfaces. So every **L3** row above has a +directly-pinned end-to-end surface, and no row's substance is carried without +being compared. Which wording, slice family and rendering rule the corpus +reaches — and the recorded disposition of each one it cannot — is the generated +ledger [`p022-cp5-inventory.md`](../docs/generated/p022-cp5-inventory.md). Two +row families are outside the replayed set by declaration, recorded in the +manifest's `rust_replay_excluded` ledger with an executable expectation: the protocol rows (§4 BR-P3 — the OBL analysis is not ported, and the bridge -refuses a protocol-bearing document rather than return an incomplete list) -and the tolerant-door coercions the typed Rust constructor cannot reach -(OD-1). +refuses a protocol-bearing document rather than return an incomplete list) and +the tolerant-door coercions the typed Rust constructor cannot reach (OD-1). diff --git a/tests/fixtures/repro/canonical_key_order.repro.json b/tests/fixtures/repro/canonical_key_order.repro.json index a8774b5f..8c4d5488 100644 --- a/tests/fixtures/repro/canonical_key_order.repro.json +++ b/tests/fixtures/repro/canonical_key_order.repro.json @@ -404,21 +404,7 @@ "layer": "verdicts", "surface_version": 1, "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "status": "produced", "document": { @@ -431,9 +417,12 @@ "component": "Zed", "event": "b.Zeta", "handler": "OnZeta", + "message": "the result of 'b.Zeta' is ignored — the IDisposable subscription is never disposed, leaking 'Zed' (leak)", "kind": "subscription token", "advisory": false, "severity": null, + "related": [], + "flow": [], "ignore_reason": null, "column": null } diff --git a/tests/fixtures/repro/canonical_key_order.trace.json b/tests/fixtures/repro/canonical_key_order.trace.json index 4e73d00a..69ea6ebc 100644 --- a/tests/fixtures/repro/canonical_key_order.trace.json +++ b/tests/fixtures/repro/canonical_key_order.trace.json @@ -473,21 +473,7 @@ "layer": "verdicts", "status": "produced", "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "order": "significant", "steps": [ @@ -504,9 +490,12 @@ "component": "Zed", "event": "b.Zeta", "handler": "OnZeta", + "message": "the result of 'b.Zeta' is ignored — the IDisposable subscription is never disposed, leaking 'Zed' (leak)", "kind": "subscription token", "advisory": false, "severity": null, + "related": [], + "flow": [], "ignore_reason": null, "column": null } diff --git a/tests/fixtures/repro/canonical_minimal.repro.json b/tests/fixtures/repro/canonical_minimal.repro.json index 454e0fc7..3ee17f35 100644 --- a/tests/fixtures/repro/canonical_minimal.repro.json +++ b/tests/fixtures/repro/canonical_minimal.repro.json @@ -271,21 +271,7 @@ "layer": "verdicts", "surface_version": 1, "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "status": "produced", "document": { diff --git a/tests/fixtures/repro/canonical_minimal.trace.json b/tests/fixtures/repro/canonical_minimal.trace.json index 7c1fcad3..bef800d9 100644 --- a/tests/fixtures/repro/canonical_minimal.trace.json +++ b/tests/fixtures/repro/canonical_minimal.trace.json @@ -328,21 +328,7 @@ "layer": "verdicts", "status": "produced", "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "order": "significant", "steps": [ diff --git a/tests/fixtures/repro/canonical_torture.repro.json b/tests/fixtures/repro/canonical_torture.repro.json index 94e405a0..5f79c399 100644 --- a/tests/fixtures/repro/canonical_torture.repro.json +++ b/tests/fixtures/repro/canonical_torture.repro.json @@ -381,21 +381,7 @@ "layer": "verdicts", "surface_version": 1, "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "status": "produced", "document": { @@ -408,9 +394,12 @@ "component": "TortureView", "event": "bus.Subscribe", "handler": "On漢", + "message": "the result of 'bus.Subscribe' is ignored — the IDisposable subscription is never disposed, leaking 'TortureView' (leak)", "kind": "subscription token", "advisory": false, "severity": null, + "related": [], + "flow": [], "ignore_reason": null, "column": null } diff --git a/tests/fixtures/repro/canonical_torture.trace.json b/tests/fixtures/repro/canonical_torture.trace.json index 8a4d13db..ba64a6bb 100644 --- a/tests/fixtures/repro/canonical_torture.trace.json +++ b/tests/fixtures/repro/canonical_torture.trace.json @@ -411,21 +411,7 @@ "layer": "verdicts", "status": "produced", "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "order": "significant", "steps": [ @@ -442,9 +428,12 @@ "component": "TortureView", "event": "bus.Subscribe", "handler": "On漢", + "message": "the result of 'bus.Subscribe' is ignored — the IDisposable subscription is never disposed, leaking 'TortureView' (leak)", "kind": "subscription token", "advisory": false, "severity": null, + "related": [], + "flow": [], "ignore_reason": null, "column": null } diff --git a/tests/fixtures/repro/di.repro.json b/tests/fixtures/repro/di.repro.json index dcf95a2f..eb60c736 100644 --- a/tests/fixtures/repro/di.repro.json +++ b/tests/fixtures/repro/di.repro.json @@ -402,21 +402,7 @@ "layer": "verdicts", "surface_version": 1, "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "status": "produced", "document": { @@ -429,9 +415,29 @@ "component": "EmailSender", "event": "AppDbContext", "handler": "", + "message": "singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext) [consumed by the 'EmailSender' constructor at EmailSender.cs:5]", "kind": "DI lifetime", "advisory": false, "severity": null, + "related": [ + [ + "EmailSender.cs", + 5, + "consuming constructor of 'EmailSender'" + ] + ], + "flow": [ + [ + "Startup.cs", + 12, + "singleton 'EmailSender' (captor)" + ], + [ + "Startup.cs", + 13, + "captures scoped service 'AppDbContext'" + ] + ], "ignore_reason": null, "column": null }, @@ -442,9 +448,34 @@ "component": "ReportService", "event": "AppDbContext", "handler": "", + "message": "singleton 'ReportService' captures scoped service 'AppDbContext' (captive dependency: ReportService -> UnitOfWork -> AppDbContext) [consumed by the 'ReportService' constructor at ReportService.cs:7]", "kind": "DI lifetime", "advisory": false, "severity": null, + "related": [ + [ + "ReportService.cs", + 7, + "consuming constructor of 'ReportService'" + ] + ], + "flow": [ + [ + "Startup.cs", + 15, + "singleton 'ReportService' (captor)" + ], + [ + "Startup.cs", + 16, + "via 'UnitOfWork'" + ], + [ + "Startup.cs", + 13, + "captures scoped service 'AppDbContext'" + ] + ], "ignore_reason": null, "column": null } diff --git a/tests/fixtures/repro/di.trace.json b/tests/fixtures/repro/di.trace.json index 4dd3e246..b8895768 100644 --- a/tests/fixtures/repro/di.trace.json +++ b/tests/fixtures/repro/di.trace.json @@ -405,21 +405,7 @@ "layer": "verdicts", "status": "produced", "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "order": "significant", "steps": [ @@ -436,9 +422,29 @@ "component": "EmailSender", "event": "AppDbContext", "handler": "", + "message": "singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext) [consumed by the 'EmailSender' constructor at EmailSender.cs:5]", "kind": "DI lifetime", "advisory": false, "severity": null, + "related": [ + [ + "EmailSender.cs", + 5, + "consuming constructor of 'EmailSender'" + ] + ], + "flow": [ + [ + "Startup.cs", + 12, + "singleton 'EmailSender' (captor)" + ], + [ + "Startup.cs", + 13, + "captures scoped service 'AppDbContext'" + ] + ], "ignore_reason": null, "column": null } @@ -452,9 +458,34 @@ "component": "ReportService", "event": "AppDbContext", "handler": "", + "message": "singleton 'ReportService' captures scoped service 'AppDbContext' (captive dependency: ReportService -> UnitOfWork -> AppDbContext) [consumed by the 'ReportService' constructor at ReportService.cs:7]", "kind": "DI lifetime", "advisory": false, "severity": null, + "related": [ + [ + "ReportService.cs", + 7, + "consuming constructor of 'ReportService'" + ] + ], + "flow": [ + [ + "Startup.cs", + 15, + "singleton 'ReportService' (captor)" + ], + [ + "Startup.cs", + 16, + "via 'UnitOfWork'" + ], + [ + "Startup.cs", + 13, + "captures scoped service 'AppDbContext'" + ] + ], "ignore_reason": null, "column": null } diff --git a/tests/fixtures/repro/digests.json b/tests/fixtures/repro/digests.json index 8e0d2f14..f25cc3a5 100644 --- a/tests/fixtures/repro/digests.json +++ b/tests/fixtures/repro/digests.json @@ -393,6 +393,24 @@ "digest": "33f0ab59bd9c1dadc09aa5b2c4f110b9540116f9b51a4e4d2e0c0085e9c96169", "bytes": 960 }, + { + "case": "verdict_dedup_key_members", + "corpus": "verdicts", + "digest": "510950daa897efb50b527b16a15165441d8d84f76299d662402935a19e0c2a05", + "bytes": 973 + }, + { + "case": "verdict_dedup_needs_the_message", + "corpus": "verdicts", + "digest": "81fc98c8a762811e57040baeb5b02f19fa6e5b58ad1388056b73811da863f685", + "bytes": 663 + }, + { + "case": "verdict_degenerate_metadata", + "corpus": "verdicts", + "digest": "b068af472cf610e2860561fe4dc7177f61bc170fdfec5e81279413e7f05d9524", + "bytes": 856 + }, { "case": "verdict_di004_call_site_anchor", "corpus": "verdicts", @@ -411,6 +429,12 @@ "digest": "33aff7fd9961eed97afe9bd78b0271d0333ab27734df86bccfee2e892a226fb6", "bytes": 1888 }, + { + "case": "verdict_di_evidence_degradations", + "corpus": "verdicts", + "digest": "e56949c13fd63bceb00fb098bbd9ddab806cfbb00bcc9a80b2a2e49f7cf6584a", + "bytes": 851 + }, { "case": "verdict_di_graph_families", "corpus": "verdicts", @@ -435,18 +459,42 @@ "digest": "f812c4f014338788c67b3e3f29f035d074d84714f048b0ff8b696b57432a3752", "bytes": 541 }, + { + "case": "verdict_eff001_slice_dropped", + "corpus": "verdicts", + "digest": "5cefb40937373c3f12d6fe5c35f1c97851fc9a071556855c4823c369689ec67b", + "bytes": 417 + }, { "case": "verdict_eff001_storm_and_memo", "corpus": "verdicts", "digest": "68d132b1eb434c314635ace94f186ea4f080cb04e79fc26d0d2feb3846f80fb5", "bytes": 1054 }, + { + "case": "verdict_flow_local_wordings", + "corpus": "verdicts", + "digest": "59d7ecea9efc0547acddc90d4868713dd50c10abede3b41250f96e66592404c3", + "bytes": 1273 + }, { "case": "verdict_multi_file_ordering", "corpus": "verdicts", "digest": "ba871ddc6cc81bd4e3185e51855561ab4b9ed5bd3b400f308a87987bad80d2a0", "bytes": 1036 }, + { + "case": "verdict_own014_escape_slice_dropped", + "corpus": "verdicts", + "digest": "48c6d1f5ad01e10b73d984a1140f542b9c56fb1c462a1e17002398362f66ed32", + "bytes": 598 + }, + { + "case": "verdict_own014_wordings", + "corpus": "verdicts", + "digest": "af69dbeed1437c27cc5fcb634231842c4ab0525ea7cc1f68e13e0148a23b12b9", + "bytes": 1035 + }, { "case": "verdict_own051_gates", "corpus": "verdicts", @@ -477,6 +525,18 @@ "digest": "7cec84e88b7002fc0dced5d9c086cbf5763f6b26a6d4f5b8236f47bb67646299", "bytes": 1460 }, + { + "case": "verdict_subscription_lambda_note", + "corpus": "verdicts", + "digest": "526566f9b3ceb3fb45fea6af1d86d0fe722df32159a05410b5cc15fcefa48001", + "bytes": 589 + }, + { + "case": "verdict_unknown_line_guards", + "corpus": "verdicts", + "digest": "94ee4ea27864e84ae6113ed4b17856f1c6882714a3865602a73f289bdb7ab8d2", + "bytes": 1216 + }, { "case": "vocab_unknown_op", "corpus": "lowered", diff --git a/tests/fixtures/repro/hoist_neg_while_body.repro.json b/tests/fixtures/repro/hoist_neg_while_body.repro.json index d7cd0f07..d6aeb54b 100644 --- a/tests/fixtures/repro/hoist_neg_while_body.repro.json +++ b/tests/fixtures/repro/hoist_neg_while_body.repro.json @@ -390,24 +390,10 @@ "layer": "verdicts", "surface_version": 1, "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "status": "refused", - "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message='undefined name'). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding." + "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message=\"undefined name 'loc_0'\"). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding." } ] } diff --git a/tests/fixtures/repro/hoist_neg_while_body.trace.json b/tests/fixtures/repro/hoist_neg_while_body.trace.json index bec6e406..6a9c2353 100644 --- a/tests/fixtures/repro/hoist_neg_while_body.trace.json +++ b/tests/fixtures/repro/hoist_neg_while_body.trace.json @@ -444,24 +444,10 @@ "layer": "verdicts", "status": "refused", "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "order": "significant", - "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message='undefined name'). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding.", + "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message=\"undefined name 'loc_0'\"). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding.", "steps": [] } ] diff --git a/tests/fixtures/repro/mosdump_degraded_duplicate_key.repro.json b/tests/fixtures/repro/mosdump_degraded_duplicate_key.repro.json index 195ec49d..4ad77691 100644 --- a/tests/fixtures/repro/mosdump_degraded_duplicate_key.repro.json +++ b/tests/fixtures/repro/mosdump_degraded_duplicate_key.repro.json @@ -481,21 +481,7 @@ "layer": "verdicts", "surface_version": 1, "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "status": "produced", "document": { @@ -508,9 +494,12 @@ "component": "Degraded", "event": "", "handler": "", + "message": "interprocedural summary inference failed (ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)); method summaries skipped — cross-method ownership transfer was not checked this run", "kind": "method summaries", "advisory": true, "severity": null, + "related": [], + "flow": [], "ignore_reason": null, "column": null } diff --git a/tests/fixtures/repro/mosdump_degraded_duplicate_key.trace.json b/tests/fixtures/repro/mosdump_degraded_duplicate_key.trace.json index 8dd511f2..132bfd31 100644 --- a/tests/fixtures/repro/mosdump_degraded_duplicate_key.trace.json +++ b/tests/fixtures/repro/mosdump_degraded_duplicate_key.trace.json @@ -529,21 +529,7 @@ "layer": "verdicts", "status": "produced", "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "order": "significant", "steps": [ @@ -560,9 +546,12 @@ "component": "Degraded", "event": "", "handler": "", + "message": "interprocedural summary inference failed (ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)); method summaries skipped — cross-method ownership transfer was not checked this run", "kind": "method summaries", "advisory": true, "severity": null, + "related": [], + "flow": [], "ignore_reason": null, "column": null } diff --git a/tests/fixtures/repro/protocol_isloaded_violation.repro.json b/tests/fixtures/repro/protocol_isloaded_violation.repro.json index 6a27c487..7c3c3b5b 100644 --- a/tests/fixtures/repro/protocol_isloaded_violation.repro.json +++ b/tests/fixtures/repro/protocol_isloaded_violation.repro.json @@ -401,21 +401,7 @@ "layer": "verdicts", "surface_version": 1, "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "status": "refused", "error": "this document declares 1 obligation protocol(s), and the protocol analysis (OBL001–005, ownlang/obligations.py) is not wired into this core yet — refusing rather than returning a verdict list with a family missing (#259 boundary; the verdict fixture ledger records the excluded reference documents)" diff --git a/tests/fixtures/repro/protocol_isloaded_violation.trace.json b/tests/fixtures/repro/protocol_isloaded_violation.trace.json index 4d419342..43bef8d7 100644 --- a/tests/fixtures/repro/protocol_isloaded_violation.trace.json +++ b/tests/fixtures/repro/protocol_isloaded_violation.trace.json @@ -363,21 +363,7 @@ "layer": "verdicts", "status": "refused", "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "order": "significant", "error": "this document declares 1 obligation protocol(s), and the protocol analysis (OBL001–005, ownlang/obligations.py) is not wired into this core yet — refusing rather than returning a verdict list with a family missing (#259 boundary; the verdict fixture ledger records the excluded reference documents)", diff --git a/tests/fixtures/repro/vocab_unknown_op.repro.json b/tests/fixtures/repro/vocab_unknown_op.repro.json index 9dc6514a..b5d6b694 100644 --- a/tests/fixtures/repro/vocab_unknown_op.repro.json +++ b/tests/fixtures/repro/vocab_unknown_op.repro.json @@ -116,21 +116,7 @@ "layer": "verdicts", "surface_version": 1, "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "status": "refused", "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)" diff --git a/tests/fixtures/repro/vocab_unknown_op.trace.json b/tests/fixtures/repro/vocab_unknown_op.trace.json index 45a43cb5..1810711b 100644 --- a/tests/fixtures/repro/vocab_unknown_op.trace.json +++ b/tests/fixtures/repro/vocab_unknown_op.trace.json @@ -126,21 +126,7 @@ "layer": "verdicts", "status": "refused", "projection": { - "kind": "partial", - "members": [ - "file", - "line", - "code", - "component", - "event", - "handler", - "kind", - "advisory", - "severity", - "ignore_reason", - "column" - ], - "reason": "own_bridge::check_facts is at the #259 checkpoint-4 surface: message synthesis (BR-V4) and the related/flow evidence slices are checkpoint 5 and are not ported, so this engine does not emit them rather than emitting them empty" + "kind": "full" }, "order": "significant", "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)", diff --git a/tests/fixtures/unreachable_branches.json b/tests/fixtures/unreachable_branches.json new file mode 100644 index 00000000..eac3ae1d --- /dev/null +++ b/tests/fixtures/unreachable_branches.json @@ -0,0 +1,13 @@ +{ + "comment": "GENERATED by tests/test_unreachable_branch_probe.py --write; do not edit. The REFERENCE's own message text for branches no facts document can reach, obtained by running check_facts with its lowering and core substituted. The Rust controls read this file instead of carrying their own copy of the text.", + "probe_version": 1, + "messages": { + "flow_local_fallback_plain": "IDisposable local 's': moved 's' at A.cs:9", + "flow_local_fallback_pooled": "pooled buffer 's': moved 's' at A.cs:9", + "own014_di_transient": "event 'src.E' is subscribed (handler 'OnE') to 'Src' — a DI transient service that outlives 'Vm'; the strong subscription promotes 'Vm' to the source's lifetime, so it can never be collected — a captive/region escape (leak, no release path)", + "own014_di_unknown_lifetime": "event 'src.E' is subscribed (handler 'OnE') to 'Src' — a DI gremlin service that outlives 'Vm'; the strong subscription promotes 'Vm' to the source's lifetime, so it can never be collected — a captive/region escape (leak, no release path)", + "own014_capture_named_source": "event 'svc.E' is subscribed (handler 'OnE') to a longer-lived source ('container') that outlives 'Vm'; the strong subscription promotes 'Vm' to the source's lifetime, so it can never be collected — a region escape (leak, no release path)", + "eff001_single_hop_chain_has_no_via": "effect re-runs on every render: dependency 'cfg' derives from 'opts', an object literal created in render scope, so its identity changes on every render; the effect performs IO, which can become a request storm — stabilise 'opts' with useMemo/useCallback (or move it out of render)", + "eff001_multi_hop_chain_has_via": "effect re-runs on every render: dependency 'cfg' derives from 'opts', an object literal created in render scope (via cfg -> opts), so its identity changes on every render; the effect performs IO, which can become a request storm — stabilise 'opts' with useMemo/useCallback (or move it out of render)" + } +} diff --git a/tests/fixtures/verdict_renders/manifest.json b/tests/fixtures/verdict_renders/manifest.json new file mode 100644 index 00000000..ae5ec74f --- /dev/null +++ b/tests/fixtures/verdict_renders/manifest.json @@ -0,0 +1,103 @@ +{ + "comment": "The rendered-surface fixture ledger (P-022 #259 checkpoint 5.3, spec/Bridge.md BR-V9). Each case names its facts document beside this file, the BR rules it pins, and the `pins` — the BR-V9 ledger rows it is the control for, which tests/verdict_surface_inventory.py reads to say whether a rule has one. Python is authoritative: regenerate with python tests/test_verdict_render_fixtures.py --write.", + "renders_version": 1, + "cases": [ + { + "name": "render_anchorless", + "rules": [ + "BR-V9" + ], + "pins": [ + "sarif_region_omitted" + ] + }, + { + "name": "render_columns", + "rules": [ + "BR-V9" + ], + "pins": [ + "sarif_start_column" + ] + }, + { + "name": "render_empty", + "rules": [ + "BR-V9" + ], + "pins": [ + "sarif_empty" + ] + }, + { + "name": "render_escaping", + "rules": [ + "BR-V9" + ], + "pins": [ + "github_esc_percent", + "github_esc_cr", + "github_esc_lf", + "github_esc_prop_colon", + "github_esc_prop_comma", + "sarif_uri_backslash" + ] + }, + { + "name": "render_evidence_slices", + "rules": [ + "BR-V9" + ], + "pins": [ + "sarif_related", + "sarif_code_flows" + ] + }, + { + "name": "render_refusal", + "rules": [ + "BR-V9" + ], + "pins": [ + "refusal_error" + ] + }, + { + "name": "render_rule_catalogue", + "rules": [ + "BR-V8", + "BR-V9" + ], + "pins": [ + "sarif_rules", + "sarif_result_order" + ] + }, + { + "name": "render_tiers_and_levels", + "rules": [ + "BR-V9" + ], + "pins": [ + "human_line", + "human_severity", + "github_line", + "github_severity", + "msbuild_line", + "msbuild_severity", + "fallback_human", + "sarif_envelope", + "sarif_driver", + "sarif_rules", + "sarif_schema_version", + "sarif_level_note", + "sarif_level_warning", + "sarif_level_error", + "sarif_level_host_warning", + "sarif_region", + "sarif_properties", + "sarif_suppressions" + ] + } + ] +} diff --git a/tests/fixtures/verdict_renders/render_anchorless.facts.json b/tests/fixtures/verdict_renders/render_anchorless.facts.json new file mode 100644 index 00000000..8b0367ed --- /dev/null +++ b/tests/fixtures/verdict_renders/render_anchorless.facts.json @@ -0,0 +1,20 @@ +{ + "ownir_version": 0, + "module": "Anchorless", + "_doc": [ + "OWN052 is anchorless by nature (file '?', line 0), so its SARIF location", + "carries an artifact uri and NO region — a region at line 0 would be a", + "schema-invalid coordinate, and a substituted line would be a well-formed", + "one pointing nowhere. The solve is forced to fail by a bare name colliding", + "with the sig-qualified key its overload set already occupies." + ], + "functions": [ + {"name": "Take", "sig": "System.IO.Stream", "file": "t1.cs", + "params": [{"name": "p", "line": 1}], + "body": [{"op": "release", "var": "p", "line": 2}]}, + {"name": "Take", "sig": "System.String", "file": "t2.cs", + "params": [{"name": "p", "line": 5}], + "body": [{"op": "use", "var": "p", "line": 6}]}, + {"name": "Take(System.IO.Stream)", "file": "clash.cs", "body": []} + ] +} diff --git a/tests/fixtures/verdict_renders/render_anchorless.renders.json b/tests/fixtures/verdict_renders/render_anchorless.renders.json new file mode 100644 index 00000000..c5f938bf --- /dev/null +++ b/tests/fixtures/verdict_renders/render_anchorless.renders.json @@ -0,0 +1,131 @@ +{ + "renders_version": 1, + "human": { + "error": [ + "?:0: error: [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]" + ], + "warning": [ + "?: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]" + ] + }, + "github": { + "error": [ + "::error file=?,line=0,title=OWN052::[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]" + ], + "warning": [ + "::warning file=?,line=0,title=OWN052::[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]" + ] + }, + "msbuild": { + "error": [ + "?(0): error 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]" + ], + "warning": [ + "?(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]" + ] + }, + "unknown-format": { + "error": [ + "?:0: error: [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]" + ], + "warning": [ + "?: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]" + ] + }, + "sarif": { + "error": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "OWN052", + "shortDescription": { + "text": "interprocedural summary inference failed -- method summaries skipped" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OWN052", + "level": "note", + "message": { + "text": "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]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "?" + } + } + } + ], + "properties": { + "resourceKind": "method summaries", + "component": "Anchorless" + } + } + ] + } + ] + }, + "warning": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "OWN052", + "shortDescription": { + "text": "interprocedural summary inference failed -- method summaries skipped" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OWN052", + "level": "note", + "message": { + "text": "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]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "?" + } + } + } + ], + "properties": { + "resourceKind": "method summaries", + "component": "Anchorless" + } + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/verdict_renders/render_columns.facts.json b/tests/fixtures/verdict_renders/render_columns.facts.json new file mode 100644 index 00000000..4e43e2ca --- /dev/null +++ b/tests/fixtures/verdict_renders/render_columns.facts.json @@ -0,0 +1,19 @@ +{ + "ownir_version": 0, + "module": "Columns", + "_doc": [ + "A reported column rides into SARIF as region.startColumn, and only ever", + "beside a real startLine. The second record reports no column at all, so", + "its region carries the line alone — absence is data, never substituted." + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + {"event": "bus.Anchored", "handler": "OnA", "line": 9, "column": 17, "source": "static"}, + {"event": "bus.Bare", "handler": "OnB", "line": 10, "source": "static"} + ] + } + ] +} diff --git a/tests/fixtures/verdict_renders/render_columns.renders.json b/tests/fixtures/verdict_renders/render_columns.renders.json new file mode 100644 index 00000000..8dce3f63 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_columns.renders.json @@ -0,0 +1,201 @@ +{ + "renders_version": 1, + "human": { + "error": [ + "Vm.cs:9: error: [OWN001] event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:10: error: [OWN001] event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ], + "warning": [ + "Vm.cs:9: warning: [OWN001] event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:10: warning: [OWN001] event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ] + }, + "github": { + "error": [ + "::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]", + "::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]" + ], + "warning": [ + "::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]", + "::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]" + ] + }, + "msbuild": { + "error": [ + "Vm.cs(9): error OWN001: event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs(10): error OWN001: event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ], + "warning": [ + "Vm.cs(9): warning OWN001: event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs(10): warning OWN001: event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ] + }, + "unknown-format": { + "error": [ + "Vm.cs:9: error: [OWN001] event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:10: error: [OWN001] event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ], + "warning": [ + "Vm.cs:9: warning: [OWN001] event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:10: warning: [OWN001] event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ] + }, + "sarif": { + "error": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "OWN001", + "shortDescription": { + "text": "owned resource not released on all paths (possible leak)" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OWN001", + "level": "error", + "message": { + "text": "event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 9, + "startColumn": 17 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.Anchored", + "handler": "OnA" + } + }, + { + "ruleId": "OWN001", + "level": "error", + "message": { + "text": "event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 10 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.Bare", + "handler": "OnB" + } + } + ] + } + ] + }, + "warning": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "OWN001", + "shortDescription": { + "text": "owned resource not released on all paths (possible leak)" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OWN001", + "level": "warning", + "message": { + "text": "event 'bus.Anchored' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 9, + "startColumn": 17 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.Anchored", + "handler": "OnA" + } + }, + { + "ruleId": "OWN001", + "level": "warning", + "message": { + "text": "event 'bus.Bare' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 10 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.Bare", + "handler": "OnB" + } + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/verdict_renders/render_empty.facts.json b/tests/fixtures/verdict_renders/render_empty.facts.json new file mode 100644 index 00000000..2f926557 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_empty.facts.json @@ -0,0 +1,13 @@ +{ + "ownir_version": 0, + "module": "Empty", + "_doc": [ + "A clean document: every line-per-finding surface is an empty list and the", + "SARIF log is a valid run with no results and an empty rule catalogue — not", + "a missing run, and not a crash." + ], + "components": [ + {"name": "Vm", "file": "Vm.cs", "subscriptions": [ + {"event": "bus.Released", "handler": "OnR", "line": 4, "released": true}]} + ] +} diff --git a/tests/fixtures/verdict_renders/render_empty.renders.json b/tests/fixtures/verdict_renders/render_empty.renders.json new file mode 100644 index 00000000..7acbab5a --- /dev/null +++ b/tests/fixtures/verdict_renders/render_empty.renders.json @@ -0,0 +1,59 @@ +{ + "renders_version": 1, + "human": { + "error": [], + "warning": [] + }, + "github": { + "error": [], + "warning": [] + }, + "msbuild": { + "error": [], + "warning": [] + }, + "unknown-format": { + "error": [], + "warning": [] + }, + "sarif": { + "error": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [] + } + ] + }, + "warning": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [] + } + ] + } + } +} diff --git a/tests/fixtures/verdict_renders/render_escaping.facts.json b/tests/fixtures/verdict_renders/render_escaping.facts.json new file mode 100644 index 00000000..98ac6d01 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_escaping.facts.json @@ -0,0 +1,21 @@ +{ + "ownir_version": 0, + "module": "Escaping", + "_doc": [ + "Every workflow-command escaping rule in one document. The component file", + "carries a ':' and a ',' (property-value separators) and a backslash (the", + "SARIF artifact URI normalises it); the event name carries a '%', a CR and", + "an LF, which are the three characters the message data escapes. The", + "msbuild and human lines carry the same text unescaped, which is what makes", + "the github line's difference legible." + ], + "components": [ + { + "name": "Vm", + "file": "src\\gen:a,b.cs", + "subscriptions": [ + {"event": "bus.50% off\r\nnext", "handler": "OnE", "line": 7, "source": "static"} + ] + } + ] +} diff --git a/tests/fixtures/verdict_renders/render_escaping.renders.json b/tests/fixtures/verdict_renders/render_escaping.renders.json new file mode 100644 index 00000000..2c68acae --- /dev/null +++ b/tests/fixtures/verdict_renders/render_escaping.renders.json @@ -0,0 +1,141 @@ +{ + "renders_version": 1, + "human": { + "error": [ + "src\\gen:a,b.cs:7: error: [OWN001] event 'bus.50% off\r\nnext' is subscribed (handler 'OnE') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ], + "warning": [ + "src\\gen:a,b.cs:7: warning: [OWN001] event 'bus.50% off\r\nnext' is subscribed (handler 'OnE') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ] + }, + "github": { + "error": [ + "::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]" + ], + "warning": [ + "::warning 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]" + ] + }, + "msbuild": { + "error": [ + "src\\gen:a,b.cs(7): error OWN001: event 'bus.50% off\r\nnext' is subscribed (handler 'OnE') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ], + "warning": [ + "src\\gen:a,b.cs(7): warning OWN001: event 'bus.50% off\r\nnext' is subscribed (handler 'OnE') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ] + }, + "unknown-format": { + "error": [ + "src\\gen:a,b.cs:7: error: [OWN001] event 'bus.50% off\r\nnext' is subscribed (handler 'OnE') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ], + "warning": [ + "src\\gen:a,b.cs:7: warning: [OWN001] event 'bus.50% off\r\nnext' is subscribed (handler 'OnE') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ] + }, + "sarif": { + "error": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "OWN001", + "shortDescription": { + "text": "owned resource not released on all paths (possible leak)" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OWN001", + "level": "error", + "message": { + "text": "event 'bus.50% off\r\nnext' is subscribed (handler 'OnE') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/gen:a,b.cs" + }, + "region": { + "startLine": 7 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.50% off\r\nnext", + "handler": "OnE" + } + } + ] + } + ] + }, + "warning": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "OWN001", + "shortDescription": { + "text": "owned resource not released on all paths (possible leak)" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OWN001", + "level": "warning", + "message": { + "text": "event 'bus.50% off\r\nnext' is subscribed (handler 'OnE') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "src/gen:a,b.cs" + }, + "region": { + "startLine": 7 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.50% off\r\nnext", + "handler": "OnE" + } + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/verdict_renders/render_evidence_slices.facts.json b/tests/fixtures/verdict_renders/render_evidence_slices.facts.json new file mode 100644 index 00000000..60ff49ab --- /dev/null +++ b/tests/fixtures/verdict_renders/render_evidence_slices.facts.json @@ -0,0 +1,19 @@ +{ + "ownir_version": 0, + "module": "Evidence", + "_doc": [ + "The two evidence projections: `related` -> relatedLocations and the ordered", + "`flow` -> codeFlows. `Blank` records a consuming ctor whose FILE is empty:", + "the bridge's relatedLocations builder filters on the line alone, unlike the", + "core's own `evidence.related_locations`, which also requires a non-empty", + "file — so this one emits an empty uri and the golden pins that difference", + "rather than letting a port 'reuse' the core builder and silently drop it." + ], + "services": [ + {"name": "App", "lifetime": "singleton", "file": "reg.cs", "line": 5, "deps": ["Db"], + "ctor_file": "App.cs", "ctor_line": 3, "ctor_type": "AppService"}, + {"name": "Db", "lifetime": "scoped", "file": "reg.cs", "line": 6, "deps": []}, + {"name": "Blank", "lifetime": "singleton", "file": "reg.cs", "line": 8, "deps": ["Db"], + "ctor_file": "", "ctor_line": 4, "ctor_type": "BlankService"} + ] +} diff --git a/tests/fixtures/verdict_renders/render_evidence_slices.renders.json b/tests/fixtures/verdict_renders/render_evidence_slices.renders.json new file mode 100644 index 00000000..2f1b79c2 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_evidence_slices.renders.json @@ -0,0 +1,415 @@ +{ + "renders_version": 1, + "human": { + "error": [ + "reg.cs:5: error: [DI001] singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'AppService' constructor at App.cs:3] [resource: DI lifetime]", + "reg.cs:8: error: [DI001] singleton 'Blank' captures scoped service 'Db' (captive dependency: Blank -> Db) [consumed by the 'BlankService' constructor at :4] [resource: DI lifetime]" + ], + "warning": [ + "reg.cs:5: warning: [DI001] singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'AppService' constructor at App.cs:3] [resource: DI lifetime]", + "reg.cs:8: warning: [DI001] singleton 'Blank' captures scoped service 'Db' (captive dependency: Blank -> Db) [consumed by the 'BlankService' constructor at :4] [resource: DI lifetime]" + ] + }, + "github": { + "error": [ + "::error file=reg.cs,line=5,title=DI001::[DI001] singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'AppService' constructor at App.cs:3] [resource: DI lifetime]", + "::error file=reg.cs,line=8,title=DI001::[DI001] singleton 'Blank' captures scoped service 'Db' (captive dependency: Blank -> Db) [consumed by the 'BlankService' constructor at :4] [resource: DI lifetime]" + ], + "warning": [ + "::warning file=reg.cs,line=5,title=DI001::[DI001] singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'AppService' constructor at App.cs:3] [resource: DI lifetime]", + "::warning file=reg.cs,line=8,title=DI001::[DI001] singleton 'Blank' captures scoped service 'Db' (captive dependency: Blank -> Db) [consumed by the 'BlankService' constructor at :4] [resource: DI lifetime]" + ] + }, + "msbuild": { + "error": [ + "reg.cs(5): error DI001: singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'AppService' constructor at App.cs:3] [resource: DI lifetime]", + "reg.cs(8): error DI001: singleton 'Blank' captures scoped service 'Db' (captive dependency: Blank -> Db) [consumed by the 'BlankService' constructor at :4] [resource: DI lifetime]" + ], + "warning": [ + "reg.cs(5): warning DI001: singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'AppService' constructor at App.cs:3] [resource: DI lifetime]", + "reg.cs(8): warning DI001: singleton 'Blank' captures scoped service 'Db' (captive dependency: Blank -> Db) [consumed by the 'BlankService' constructor at :4] [resource: DI lifetime]" + ] + }, + "unknown-format": { + "error": [ + "reg.cs:5: error: [DI001] singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'AppService' constructor at App.cs:3] [resource: DI lifetime]", + "reg.cs:8: error: [DI001] singleton 'Blank' captures scoped service 'Db' (captive dependency: Blank -> Db) [consumed by the 'BlankService' constructor at :4] [resource: DI lifetime]" + ], + "warning": [ + "reg.cs:5: warning: [DI001] singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'AppService' constructor at App.cs:3] [resource: DI lifetime]", + "reg.cs:8: warning: [DI001] singleton 'Blank' captures scoped service 'Db' (captive dependency: Blank -> Db) [consumed by the 'BlankService' constructor at :4] [resource: DI lifetime]" + ] + }, + "sarif": { + "error": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "DI001", + "shortDescription": { + "text": "captive dependency: a shorter-lived service is captured by a longer-lived one" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "DI001", + "level": "error", + "message": { + "text": "singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'AppService' constructor at App.cs:3] [resource: DI lifetime]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 5 + } + } + } + ], + "properties": { + "resourceKind": "DI lifetime", + "component": "App", + "event": "Db" + }, + "relatedLocations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "App.cs" + }, + "region": { + "startLine": 3 + } + }, + "message": { + "text": "consuming constructor of 'AppService'" + } + } + ], + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 5 + } + }, + "message": { + "text": "singleton 'App' (captor)" + } + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 6 + } + }, + "message": { + "text": "captures scoped service 'Db'" + } + } + } + ] + } + ] + } + ] + }, + { + "ruleId": "DI001", + "level": "error", + "message": { + "text": "singleton 'Blank' captures scoped service 'Db' (captive dependency: Blank -> Db) [consumed by the 'BlankService' constructor at :4] [resource: DI lifetime]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 8 + } + } + } + ], + "properties": { + "resourceKind": "DI lifetime", + "component": "Blank", + "event": "Db" + }, + "relatedLocations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "" + }, + "region": { + "startLine": 4 + } + }, + "message": { + "text": "consuming constructor of 'BlankService'" + } + } + ], + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 8 + } + }, + "message": { + "text": "singleton 'Blank' (captor)" + } + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 6 + } + }, + "message": { + "text": "captures scoped service 'Db'" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "warning": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "DI001", + "shortDescription": { + "text": "captive dependency: a shorter-lived service is captured by a longer-lived one" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "DI001", + "level": "warning", + "message": { + "text": "singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'AppService' constructor at App.cs:3] [resource: DI lifetime]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 5 + } + } + } + ], + "properties": { + "resourceKind": "DI lifetime", + "component": "App", + "event": "Db" + }, + "relatedLocations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "App.cs" + }, + "region": { + "startLine": 3 + } + }, + "message": { + "text": "consuming constructor of 'AppService'" + } + } + ], + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 5 + } + }, + "message": { + "text": "singleton 'App' (captor)" + } + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 6 + } + }, + "message": { + "text": "captures scoped service 'Db'" + } + } + } + ] + } + ] + } + ] + }, + { + "ruleId": "DI001", + "level": "warning", + "message": { + "text": "singleton 'Blank' captures scoped service 'Db' (captive dependency: Blank -> Db) [consumed by the 'BlankService' constructor at :4] [resource: DI lifetime]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 8 + } + } + } + ], + "properties": { + "resourceKind": "DI lifetime", + "component": "Blank", + "event": "Db" + }, + "relatedLocations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "" + }, + "region": { + "startLine": 4 + } + }, + "message": { + "text": "consuming constructor of 'BlankService'" + } + } + ], + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 8 + } + }, + "message": { + "text": "singleton 'Blank' (captor)" + } + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "reg.cs" + }, + "region": { + "startLine": 6 + } + }, + "message": { + "text": "captures scoped service 'Db'" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/verdict_renders/render_refusal.facts.json b/tests/fixtures/verdict_renders/render_refusal.facts.json new file mode 100644 index 00000000..0b3751c0 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_refusal.facts.json @@ -0,0 +1,12 @@ +{ + "ownir_version": 0, + "module": "Refusal", + "_doc": [ + "A document the bridge refuses has no findings to render, so the rendered", + "surface is the refusal text — the same `{\"error\": ...}` shape the verdict", + "surface uses. The vocabulary skew is the loudest refusal class." + ], + "functions": [ + {"name": "F.M", "file": "F.cs", "body": [{"op": "try", "line": 2}]} + ] +} diff --git a/tests/fixtures/verdict_renders/render_refusal.renders.json b/tests/fixtures/verdict_renders/render_refusal.renders.json new file mode 100644 index 00000000..63c11278 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_refusal.renders.json @@ -0,0 +1,4 @@ +{ + "renders_version": 1, + "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)" +} diff --git a/tests/fixtures/verdict_renders/render_rule_catalogue.facts.json b/tests/fixtures/verdict_renders/render_rule_catalogue.facts.json new file mode 100644 index 00000000..e7a64734 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_rule_catalogue.facts.json @@ -0,0 +1,24 @@ +{ + "ownir_version": 0, + "module": "Catalogue", + "_doc": [ + "The SARIF log's TWO orderings, in a document where they disagree. The", + "advisory OWN050 is at the first line and the leaks after it, so the results", + "run OWN050, OWN001, OWN001 in the bridge's own order (BR-V8) while the rule", + "catalogue must run OWN001, OWN050 — sorted and deduplicated. A log that", + "sorted its results, or left its catalogue in encounter order, or listed", + "OWN001 twice, all read plausibly and are all wrong." + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + {"resource": "unresolved-subscription", "event": "ext.Unknown", "handler": "OnU", + "line": 1}, + {"event": "bus.First", "handler": "OnF", "line": 5, "source": "static"}, + {"event": "bus.Second", "handler": "OnS", "line": 6, "source": "static"} + ] + } + ] +} diff --git a/tests/fixtures/verdict_renders/render_rule_catalogue.renders.json b/tests/fixtures/verdict_renders/render_rule_catalogue.renders.json new file mode 100644 index 00000000..584e1d69 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_rule_catalogue.renders.json @@ -0,0 +1,269 @@ +{ + "renders_version": 1, + "human": { + "error": [ + "Vm.cs:1: error: [OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]", + "Vm.cs:5: error: [OWN001] event 'bus.First' is subscribed (handler 'OnF') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:6: error: [OWN001] event 'bus.Second' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ], + "warning": [ + "Vm.cs:1: 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]", + "Vm.cs:5: warning: [OWN001] event 'bus.First' is subscribed (handler 'OnF') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:6: warning: [OWN001] event 'bus.Second' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ] + }, + "github": { + "error": [ + "::error file=Vm.cs,line=1,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]", + "::error file=Vm.cs,line=5,title=OWN001::[OWN001] event 'bus.First' is subscribed (handler 'OnF') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "::error file=Vm.cs,line=6,title=OWN001::[OWN001] event 'bus.Second' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ], + "warning": [ + "::warning file=Vm.cs,line=1,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]", + "::warning file=Vm.cs,line=5,title=OWN001::[OWN001] event 'bus.First' is subscribed (handler 'OnF') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "::warning file=Vm.cs,line=6,title=OWN001::[OWN001] event 'bus.Second' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ] + }, + "msbuild": { + "error": [ + "Vm.cs(1): error OWN050: cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]", + "Vm.cs(5): error OWN001: event 'bus.First' is subscribed (handler 'OnF') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs(6): error OWN001: event 'bus.Second' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ], + "warning": [ + "Vm.cs(1): 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]", + "Vm.cs(5): warning OWN001: event 'bus.First' is subscribed (handler 'OnF') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs(6): warning OWN001: event 'bus.Second' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ] + }, + "unknown-format": { + "error": [ + "Vm.cs:1: error: [OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]", + "Vm.cs:5: error: [OWN001] event 'bus.First' is subscribed (handler 'OnF') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:6: error: [OWN001] event 'bus.Second' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ], + "warning": [ + "Vm.cs:1: 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]", + "Vm.cs:5: warning: [OWN001] event 'bus.First' is subscribed (handler 'OnF') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:6: warning: [OWN001] event 'bus.Second' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + ] + }, + "sarif": { + "error": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "OWN001", + "shortDescription": { + "text": "owned resource not released on all paths (possible leak)" + } + }, + { + "id": "OWN050", + "shortDescription": { + "text": "declaring type unresolved -- leakage analysis skipped" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OWN050", + "level": "note", + "message": { + "text": "cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 1 + } + } + } + ], + "properties": { + "resourceKind": "unresolved reference", + "component": "Vm", + "event": "ext.Unknown", + "handler": "OnU" + } + }, + { + "ruleId": "OWN001", + "level": "error", + "message": { + "text": "event 'bus.First' is subscribed (handler 'OnF') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 5 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.First", + "handler": "OnF" + } + }, + { + "ruleId": "OWN001", + "level": "error", + "message": { + "text": "event 'bus.Second' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 6 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.Second", + "handler": "OnS" + } + } + ] + } + ] + }, + "warning": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "OWN001", + "shortDescription": { + "text": "owned resource not released on all paths (possible leak)" + } + }, + { + "id": "OWN050", + "shortDescription": { + "text": "declaring type unresolved -- leakage analysis skipped" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OWN050", + "level": "note", + "message": { + "text": "cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 1 + } + } + } + ], + "properties": { + "resourceKind": "unresolved reference", + "component": "Vm", + "event": "ext.Unknown", + "handler": "OnU" + } + }, + { + "ruleId": "OWN001", + "level": "warning", + "message": { + "text": "event 'bus.First' is subscribed (handler 'OnF') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 5 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.First", + "handler": "OnF" + } + }, + { + "ruleId": "OWN001", + "level": "warning", + "message": { + "text": "event 'bus.Second' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 6 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.Second", + "handler": "OnS" + } + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/verdict_renders/render_tiers_and_levels.facts.json b/tests/fixtures/verdict_renders/render_tiers_and_levels.facts.json new file mode 100644 index 00000000..31ef0c70 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_tiers_and_levels.facts.json @@ -0,0 +1,26 @@ +{ + "ownir_version": 0, + "module": "Tiers", + "_doc": [ + "One document carrying all three SARIF levels and both suppression states:", + "an advisory OWN050 (note), an injected subscription (intrinsic warning), a", + "static-source leak (error), and a suppressed finding that stays in results", + "with a `suppressions` array. Rendered at both host severities, so the", + "downgrade rule is visible: --severity warning lowers the error and leaves", + "the note a note." + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + {"event": "bus.Injected", "handler": "OnI", "line": 10, "source": "injected"}, + {"event": "SystemEvents.Static", "handler": "OnS", "line": 11, "source": "static"}, + {"event": "bus.Ignored", "handler": "OnG", "line": 12, "source": "static", + "ignore_reason": "owned by the host container"}, + {"resource": "unresolved-subscription", "event": "ext.Unknown", "handler": "OnU", + "line": 13} + ] + } + ] +} diff --git a/tests/fixtures/verdict_renders/render_tiers_and_levels.renders.json b/tests/fixtures/verdict_renders/render_tiers_and_levels.renders.json new file mode 100644 index 00000000..24753c2f --- /dev/null +++ b/tests/fixtures/verdict_renders/render_tiers_and_levels.renders.json @@ -0,0 +1,339 @@ +{ + "renders_version": 1, + "human": { + "error": [ + "Vm.cs:10: error: [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]", + "Vm.cs:11: error: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:12: error: [OWN001] event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:13: error: [OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]" + ], + "warning": [ + "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]", + "Vm.cs:11: warning: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:12: warning: [OWN001] event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.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]" + ] + }, + "github": { + "error": [ + "::error 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]", + "::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]", + "::error file=Vm.cs,line=12,title=OWN001::[OWN001] event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "::error 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]" + ], + "warning": [ + "::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]", + "::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]", + "::warning file=Vm.cs,line=12,title=OWN001::[OWN001] event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "::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]" + ] + }, + "msbuild": { + "error": [ + "Vm.cs(10): error 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]", + "Vm.cs(11): error OWN001: event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs(12): error OWN001: event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs(13): error OWN050: cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]" + ], + "warning": [ + "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]", + "Vm.cs(11): warning OWN001: event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs(12): warning OWN001: event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.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]" + ] + }, + "unknown-format": { + "error": [ + "Vm.cs:10: error: [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]", + "Vm.cs:11: error: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:12: error: [OWN001] event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:13: error: [OWN050] cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]" + ], + "warning": [ + "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]", + "Vm.cs:11: warning: [OWN001] event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.cs:12: warning: [OWN001] event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]", + "Vm.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]" + ] + }, + "sarif": { + "error": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "OWN001", + "shortDescription": { + "text": "owned resource not released on all paths (possible leak)" + } + }, + { + "id": "OWN050", + "shortDescription": { + "text": "declaring type unresolved -- leakage analysis skipped" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OWN001", + "level": "warning", + "message": { + "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]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 10 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.Injected", + "handler": "OnI" + } + }, + { + "ruleId": "OWN001", + "level": "error", + "message": { + "text": "event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 11 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "SystemEvents.Static", + "handler": "OnS" + } + }, + { + "ruleId": "OWN001", + "level": "error", + "message": { + "text": "event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 12 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.Ignored", + "handler": "OnG" + }, + "suppressions": [ + { + "kind": "inSource", + "justification": "owned by the host container" + } + ] + }, + { + "ruleId": "OWN050", + "level": "note", + "message": { + "text": "cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 13 + } + } + } + ], + "properties": { + "resourceKind": "unresolved reference", + "component": "Vm", + "event": "ext.Unknown", + "handler": "OnU" + } + } + ] + } + ] + }, + "warning": { + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Owen", + "informationUri": "https://github.com/PhysShell/Own.NET", + "rules": [ + { + "id": "OWN001", + "shortDescription": { + "text": "owned resource not released on all paths (possible leak)" + } + }, + { + "id": "OWN050", + "shortDescription": { + "text": "declaring type unresolved -- leakage analysis skipped" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OWN001", + "level": "warning", + "message": { + "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]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 10 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.Injected", + "handler": "OnI" + } + }, + { + "ruleId": "OWN001", + "level": "warning", + "message": { + "text": "event 'SystemEvents.Static' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 11 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "SystemEvents.Static", + "handler": "OnS" + } + }, + { + "ruleId": "OWN001", + "level": "warning", + "message": { + "text": "event 'bus.Ignored' is subscribed (handler 'OnG') but never unsubscribed — the source keeps 'Vm' alive (leak) [resource: subscription token]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 12 + } + } + } + ], + "properties": { + "resourceKind": "subscription token", + "component": "Vm", + "event": "bus.Ignored", + "handler": "OnG" + }, + "suppressions": [ + { + "kind": "inSource", + "justification": "owned by the host container" + } + ] + }, + { + "ruleId": "OWN050", + "level": "note", + "message": { + "text": "cannot verify 'ext.Unknown' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped [resource: unresolved reference]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "Vm.cs" + }, + "region": { + "startLine": 13 + } + } + } + ], + "properties": { + "resourceKind": "unresolved reference", + "component": "Vm", + "event": "ext.Unknown", + "handler": "OnU" + } + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/verdicts/manifest.json b/tests/fixtures/verdicts/manifest.json index 4cd4aa00..5f3fed3a 100644 --- a/tests/fixtures/verdicts/manifest.json +++ b/tests/fixtures/verdicts/manifest.json @@ -190,6 +190,77 @@ "BR-D2", "BR-P1" ] + }, + { + "name": "verdict_flow_local_wordings", + "rules": [ + "BR-V4", + "BR-V5" + ] + }, + { + "name": "verdict_own014_wordings", + "rules": [ + "BR-L1", + "BR-V4", + "BR-V5" + ] + }, + { + "name": "verdict_own014_escape_slice_dropped", + "rules": [ + "BR-V4", + "BR-V5" + ] + }, + { + "name": "verdict_subscription_lambda_note", + "rules": [ + "BR-V4", + "BR-V6" + ] + }, + { + "name": "verdict_di_evidence_degradations", + "rules": [ + "BR-P1", + "BR-V5" + ] + }, + { + "name": "verdict_eff001_slice_dropped", + "rules": [ + "BR-P2", + "BR-V5" + ] + }, + { + "name": "verdict_unknown_line_guards", + "rules": [ + "BR-V5" + ] + }, + { + "name": "verdict_degenerate_metadata", + "rules": [ + "BR-P1", + "BR-V4", + "BR-V5" + ] + }, + { + "name": "verdict_dedup_needs_the_message", + "rules": [ + "BR-V4", + "BR-V7" + ] + }, + { + "name": "verdict_dedup_key_members", + "rules": [ + "BR-V4", + "BR-V7" + ] } ] } diff --git a/tests/fixtures/verdicts/verdict_dedup_key_members.facts.json b/tests/fixtures/verdicts/verdict_dedup_key_members.facts.json new file mode 100644 index 00000000..cf625c8e --- /dev/null +++ b/tests/fixtures/verdicts/verdict_dedup_key_members.facts.json @@ -0,0 +1,31 @@ +{ + "ownir_version": 0, + "module": "DedupKeyMembers", + "_doc": [ + "BR-V7 key members that survive only because they are IN the key, now that", + "`message` is in it too. Each pair is byte-identical on every other member", + "INCLUDING the message, so dropping the named member collapses two findings", + "into one. `_c` is disposed by two different methods and the field wording", + "names neither, so `handler` is what keeps them apart; `s` leaks the same way", + "in two methods of one file and the flow-local wording names no component, so", + "`component` is." + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + {"resource": "disposable", "event": "_c", "handler": "DisposeReader", + "line": 4, "type": "SqlConnection"}, + {"resource": "disposable", "event": "_c", "handler": "DisposeWriter", + "line": 4, "type": "SqlConnection"} + ] + } + ], + "functions": [ + {"name": "Reader.Read", "file": "Io.cs", + "body": [{"op": "acquire", "var": "s", "line": 3}]}, + {"name": "Writer.Write", "file": "Io.cs", + "body": [{"op": "acquire", "var": "s", "line": 3}]} + ] +} diff --git a/tests/fixtures/verdicts/verdict_dedup_key_members.verdicts.json b/tests/fixtures/verdicts/verdict_dedup_key_members.verdicts.json new file mode 100644 index 00000000..f31c055d --- /dev/null +++ b/tests/fixtures/verdicts/verdict_dedup_key_members.verdicts.json @@ -0,0 +1,69 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Io.cs", + "line": 3, + "code": "OWN001", + "component": "Reader.Read", + "event": "s", + "handler": "", + "message": "IDisposable local 's' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Io.cs", + "line": 3, + "code": "OWN001", + "component": "Writer.Write", + "event": "s", + "handler": "", + "message": "IDisposable local 's' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 4, + "code": "OWN001", + "component": "Vm", + "event": "_c", + "handler": "DisposeReader", + "message": "IDisposable field '_c' (type 'SqlConnection') is never disposed — its owner 'Vm' leaks it (leak)", + "kind": "disposable field", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 4, + "code": "OWN001", + "component": "Vm", + "event": "_c", + "handler": "DisposeWriter", + "message": "IDisposable field '_c' (type 'SqlConnection') is never disposed — its owner 'Vm' leaks it (leak)", + "kind": "disposable field", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_dedup_needs_the_message.facts.json b/tests/fixtures/verdicts/verdict_dedup_needs_the_message.facts.json new file mode 100644 index 00000000..6247bfa0 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_dedup_needs_the_message.facts.json @@ -0,0 +1,21 @@ +{ + "ownir_version": 0, + "module": "DedupNeedsTheMessage", + "_doc": [ + "BR-V7's `message` member, which cp4 could not carry and argued was", + "redundant. Two disposable fields share a file, line, component, event,", + "handler, kind, tier and suppression — every other member of the dedup key —", + "and differ only in the `type` that rides into the message. Without `message`", + "in the key the second collapses into the first and a leak is lost." + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + {"resource": "disposable", "event": "_c", "handler": "", "line": 4, "type": "SqlConnection"}, + {"resource": "disposable", "event": "_c", "handler": "", "line": 4, "type": "SqlTransaction"} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_dedup_needs_the_message.verdicts.json b/tests/fixtures/verdicts/verdict_dedup_needs_the_message.verdicts.json new file mode 100644 index 00000000..0dd944bd --- /dev/null +++ b/tests/fixtures/verdicts/verdict_dedup_needs_the_message.verdicts.json @@ -0,0 +1,37 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Vm.cs", + "line": 4, + "code": "OWN001", + "component": "Vm", + "event": "_c", + "handler": "", + "message": "IDisposable field '_c' (type 'SqlConnection') is never disposed — its owner 'Vm' leaks it (leak)", + "kind": "disposable field", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 4, + "code": "OWN001", + "component": "Vm", + "event": "_c", + "handler": "", + "message": "IDisposable field '_c' (type 'SqlTransaction') is never disposed — its owner 'Vm' leaks it (leak)", + "kind": "disposable field", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_degenerate_metadata.facts.json b/tests/fixtures/verdicts/verdict_degenerate_metadata.facts.json new file mode 100644 index 00000000..aa0bacff --- /dev/null +++ b/tests/fixtures/verdicts/verdict_degenerate_metadata.facts.json @@ -0,0 +1,26 @@ +{ + "ownir_version": 0, + "module": "DegenerateMetadata", + "_doc": [ + "Metadata that is PRESENT and degenerate, which is not the same as absent.", + "`_conn` carries an empty `type`: the parenthetical is a truthiness test, so", + "it emits nothing rather than ' (type '')'. `App` records a consuming ctor", + "location whose impl type is the extractor's '?' placeholder: both the", + "message tail and the related label drop to the bare 'the constructor' /", + "'consuming constructor' rather than quoting a placeholder as a type name." + ], + "services": [ + {"name": "App", "lifetime": "singleton", "file": "reg.cs", "line": 5, "deps": ["Db"], + "ctor_file": "App.cs", "ctor_line": 3, "ctor_type": "?"}, + {"name": "Db", "lifetime": "scoped", "file": "reg.cs", "line": 6, "deps": []} + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + {"resource": "disposable", "event": "_conn", "handler": "", "line": 4, "type": ""} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_degenerate_metadata.verdicts.json b/tests/fixtures/verdicts/verdict_degenerate_metadata.verdicts.json new file mode 100644 index 00000000..38368a60 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_degenerate_metadata.verdicts.json @@ -0,0 +1,54 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Vm.cs", + "line": 4, + "code": "OWN001", + "component": "Vm", + "event": "_conn", + "handler": "", + "message": "IDisposable field '_conn' is never disposed — its owner 'Vm' leaks it (leak)", + "kind": "disposable field", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "reg.cs", + "line": 5, + "code": "DI001", + "component": "App", + "event": "Db", + "handler": "", + "message": "singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the constructor at App.cs:3]", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [ + [ + "App.cs", + 3, + "consuming constructor" + ] + ], + "flow": [ + [ + "reg.cs", + 5, + "singleton 'App' (captor)" + ], + [ + "reg.cs", + 6, + "captures scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di_evidence_degradations.facts.json b/tests/fixtures/verdicts/verdict_di_evidence_degradations.facts.json new file mode 100644 index 00000000..745e2373 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di_evidence_degradations.facts.json @@ -0,0 +1,19 @@ +{ + "ownir_version": 0, + "module": "DiEvidenceDegradations", + "_doc": [ + "Two BR-V5 degradations on the DI families. `App` records a consuming ctor", + "LOCATION but no impl type, so both the message tail and the related label", + "degrade to the bare 'the constructor' / 'consuming constructor' rather than", + "guessing a name. `Head` has no registration line, so its captor hop is", + "skipped and the retention path emits ONE step — di_path_steps has no", + "'shorter than two steps' guard, unlike every other slice builder." + ], + "services": [ + {"name": "App", "lifetime": "singleton", "file": "reg.cs", "line": 5, "deps": ["Db"], + "ctor_file": "App.cs", "ctor_line": 3}, + {"name": "Db", "lifetime": "scoped", "file": "reg.cs", "line": 6, "deps": []}, + {"name": "Head", "lifetime": "singleton", "file": "reg.cs", "line": 0, "deps": ["Tail"]}, + {"name": "Tail", "lifetime": "scoped", "file": "reg.cs", "line": 9, "deps": []} + ] +} diff --git a/tests/fixtures/verdicts/verdict_di_evidence_degradations.verdicts.json b/tests/fixtures/verdicts/verdict_di_evidence_degradations.verdicts.json new file mode 100644 index 00000000..6298fe66 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di_evidence_degradations.verdicts.json @@ -0,0 +1,60 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "reg.cs", + "line": 0, + "code": "DI001", + "component": "Head", + "event": "Tail", + "handler": "", + "message": "singleton 'Head' captures scoped service 'Tail' (captive dependency: Head -> Tail)", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "reg.cs", + 9, + "captures scoped service 'Tail'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "reg.cs", + "line": 5, + "code": "DI001", + "component": "App", + "event": "Db", + "handler": "", + "message": "singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the constructor at App.cs:3]", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [ + [ + "App.cs", + 3, + "consuming constructor" + ] + ], + "flow": [ + [ + "reg.cs", + 5, + "singleton 'App' (captor)" + ], + [ + "reg.cs", + 6, + "captures scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_eff001_slice_dropped.facts.json b/tests/fixtures/verdicts/verdict_eff001_slice_dropped.facts.json new file mode 100644 index 00000000..ee73b2f8 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_eff001_slice_dropped.facts.json @@ -0,0 +1,13 @@ +{ + "ownir_version": 0, + "module": "Eff001NoSlice", + "_doc": [ + "BR-V5's line-guard on the effect slice: the storm is a verdict either way,", + "but an effect whose own call line is unknown cannot anchor the re-run hop,", + "so no slice is emitted (and none is faked at line 0)." + ], + "effects": [ + {"component": "Widget", "file": "Widget.tsx", "line": 0, "io": true, "deps": ["opts"], + "bindings": [{"name": "opts", "init": "object", "refs": [], "line": 4}]} + ] +} diff --git a/tests/fixtures/verdicts/verdict_eff001_slice_dropped.verdicts.json b/tests/fixtures/verdicts/verdict_eff001_slice_dropped.verdicts.json new file mode 100644 index 00000000..cd29b5f3 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_eff001_slice_dropped.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Widget.tsx", + "line": 0, + "code": "EFF001", + "component": "Widget", + "event": "opts", + "handler": "", + "message": "effect re-runs on every render: dependency 'opts' is an object literal created in render scope, so its identity changes on every render; the effect performs IO, which can become a request storm — stabilise 'opts' with useMemo/useCallback (or move it out of render)", + "kind": "react effect", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_flow_local_wordings.facts.json b/tests/fixtures/verdicts/verdict_flow_local_wordings.facts.json new file mode 100644 index 00000000..e983201b --- /dev/null +++ b/tests/fixtures/verdicts/verdict_flow_local_wordings.facts.json @@ -0,0 +1,50 @@ +{ + "ownir_version": 0, + "module": "FlowLocalWordings", + "_doc": [ + "BR-V4's flow-local wordings the swept corpora never mint, one function each", + "so a handle cannot be shared: OWN009 (may-use-after-release) on a plain", + "IDisposable and on a pooled buffer, and the pooled twins of OWN002", + "(use-after-return) and OWN003 (double return). Each also carries its BR-V5", + "origin -> violation slice, so the 'rented' vs 'acquired' origin wording is", + "pinned on both sides of the pool split." + ], + "functions": [ + { + "name": "Wordings.MaybeUsedPlain", + "file": "W.cs", + "body": [ + {"op": "acquire", "var": "s", "line": 1}, + {"op": "if", "line": 2, "then": [{"op": "release", "var": "s", "line": 3}], "else": []}, + {"op": "use", "var": "s", "line": 5} + ] + }, + { + "name": "Wordings.MaybeUsedPooled", + "file": "W.cs", + "body": [ + {"op": "acquire", "var": "b", "kind": "pool", "line": 11}, + {"op": "if", "line": 12, "then": [{"op": "release", "var": "b", "line": 13}], "else": []}, + {"op": "use", "var": "b", "line": 15} + ] + }, + { + "name": "Wordings.UsedAfterReturn", + "file": "W.cs", + "body": [ + {"op": "acquire", "var": "u", "kind": "pool", "line": 21}, + {"op": "release", "var": "u", "line": 22}, + {"op": "use", "var": "u", "line": 23} + ] + }, + { + "name": "Wordings.ReturnedTwice", + "file": "W.cs", + "body": [ + {"op": "acquire", "var": "d", "kind": "pool", "line": 31}, + {"op": "release", "var": "d", "line": 32}, + {"op": "release", "var": "d", "line": 33} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_flow_local_wordings.verdicts.json b/tests/fixtures/verdicts/verdict_flow_local_wordings.verdicts.json new file mode 100644 index 00000000..ed424c31 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_flow_local_wordings.verdicts.json @@ -0,0 +1,145 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "W.cs", + "line": 1, + "code": "OWN001", + "component": "Wordings.MaybeUsedPlain", + "event": "s", + "handler": "", + "message": "IDisposable local 's' may not be disposed on every path (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "W.cs", + "line": 1, + "code": "OWN009", + "component": "Wordings.MaybeUsedPlain", + "event": "s", + "handler": "", + "message": "IDisposable local 's' may be used after disposal on some path", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "W.cs", + 1, + "acquired 's' here" + ], + [ + "W.cs", + 5, + "may be used here after release on some path" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "W.cs", + "line": 11, + "code": "OWN001", + "component": "Wordings.MaybeUsedPooled", + "event": "b", + "handler": "", + "message": "pooled buffer 'b' may not be returned to the pool on every path (leak)", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "W.cs", + "line": 11, + "code": "OWN009", + "component": "Wordings.MaybeUsedPooled", + "event": "b", + "handler": "", + "message": "pooled buffer 'b' may be used after being returned on some path", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "W.cs", + 11, + "rented 'b' here" + ], + [ + "W.cs", + 15, + "may be used here after release on some path" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "W.cs", + "line": 21, + "code": "OWN002", + "component": "Wordings.UsedAfterReturn", + "event": "u", + "handler": "", + "message": "pooled buffer 'u' is used after it is returned to the pool", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "W.cs", + 21, + "rented 'u' here" + ], + [ + "W.cs", + 23, + "used here after it was released/returned" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "W.cs", + "line": 31, + "code": "OWN003", + "component": "Wordings.ReturnedTwice", + "event": "d", + "handler": "", + "message": "pooled buffer 'd' is returned to the pool more than once", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "W.cs", + 31, + "rented 'd' here" + ], + [ + "W.cs", + 33, + "released/returned here a second time" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_own014_escape_slice_dropped.facts.json b/tests/fixtures/verdicts/verdict_own014_escape_slice_dropped.facts.json new file mode 100644 index 00000000..5377c85b --- /dev/null +++ b/tests/fixtures/verdicts/verdict_own014_escape_slice_dropped.facts.json @@ -0,0 +1,23 @@ +{ + "ownir_version": 0, + "module": "Own014NoSlice", + "_doc": [ + "BR-V5's 'a slice shorter than two steps is dropped', on the OWN014 escape", + "slice: the source IS DI-registered (so the captive wording fires and names", + "its lifetime) but its registration line is unknown, so the second hop cannot", + "be built and the lone subscribe step is discarded rather than emitted." + ], + "services": [ + {"name": "Bus", "lifetime": "singleton", "file": "reg.cs", "line": 0, "deps": []} + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + {"event": "bus.Changed", "handler": "OnBus", "line": 11, + "source": "injected", "source_type": "Bus"} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_own014_escape_slice_dropped.verdicts.json b/tests/fixtures/verdicts/verdict_own014_escape_slice_dropped.verdicts.json new file mode 100644 index 00000000..79929555 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_own014_escape_slice_dropped.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Vm.cs", + "line": 11, + "code": "OWN014", + "component": "Vm", + "event": "bus.Changed", + "handler": "OnBus", + "message": "event 'bus.Changed' is subscribed (handler 'OnBus') to 'Bus' — a DI singleton (application-lifetime) service that outlives 'Vm'; the strong subscription promotes 'Vm' to the source's lifetime, so it can never be collected — a captive/region escape (leak, no release path)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_own014_wordings.facts.json b/tests/fixtures/verdicts/verdict_own014_wordings.facts.json new file mode 100644 index 00000000..fba21931 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_own014_wordings.facts.json @@ -0,0 +1,30 @@ +{ + "ownir_version": 0, + "module": "Own014Wordings", + "_doc": [ + "The OWN014 wordings the swept corpora never mint. `Cache` is a DI SCOPED", + "source captured by a transient subscriber (the corpora only ever reach the", + "singleton phrase); `Bus` is subscribed by an inline lambda, which appends the", + "no-'-=' note to the captive wording; `SystemEvents.Session` is the same note", + "on the capture route's own (region-escape) wording." + ], + "services": [ + {"name": "Cache", "lifetime": "scoped", "file": "reg.cs", "line": 3, "deps": []}, + {"name": "Bus", "lifetime": "singleton", "file": "reg.cs", "line": 4, "deps": []}, + {"name": "Vm", "lifetime": "transient", "file": "reg.cs", "line": 5, "deps": []} + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + {"event": "cache.Changed", "handler": "OnCache", "line": 11, + "source": "injected", "source_type": "Cache"}, + {"event": "bus.Changed", "handler": "", "line": 12, + "source": "injected", "source_type": "Bus", "lambda": true}, + {"resource": "capture", "event": "SystemEvents.Session", "handler": "", + "line": 13, "source": "static", "lambda": true} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_own014_wordings.verdicts.json b/tests/fixtures/verdicts/verdict_own014_wordings.verdicts.json new file mode 100644 index 00000000..db8ce1e6 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_own014_wordings.verdicts.json @@ -0,0 +1,75 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Vm.cs", + "line": 11, + "code": "OWN014", + "component": "Vm", + "event": "cache.Changed", + "handler": "OnCache", + "message": "event 'cache.Changed' is subscribed (handler 'OnCache') to 'Cache' — a DI scoped service that outlives 'Vm'; the strong subscription promotes 'Vm' to the source's lifetime, so it can never be collected — a captive/region escape (leak, no release path)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Vm.cs", + 11, + "'Vm' subscribes 'cache.Changed' to 'Cache' here" + ], + [ + "reg.cs", + 3, + "source 'Cache' (scoped) registered here — outlives 'Vm'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 12, + "code": "OWN014", + "component": "Vm", + "event": "bus.Changed", + "handler": "", + "message": "event 'bus.Changed' is subscribed (handler '') to 'Bus' — a DI singleton (application-lifetime) service that outlives 'Vm'; the strong subscription promotes 'Vm' to the source's lifetime, so it can never be collected — a captive/region escape (leak, no release path — and being an inline lambda it has no '-=' handle, so it could never be detached)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Vm.cs", + 12, + "'Vm' subscribes 'bus.Changed' to 'Bus' here" + ], + [ + "reg.cs", + 4, + "source 'Bus' (singleton) registered here — outlives 'Vm'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 13, + "code": "OWN014", + "component": "Vm", + "event": "SystemEvents.Session", + "handler": "", + "message": "event 'SystemEvents.Session' is subscribed (handler '') to a static (process-lived) event source that outlives 'Vm'; the strong subscription promotes 'Vm' to the source's lifetime, so it can never be collected — a region escape (leak, no release path — and being an inline lambda it has no '-=' handle, so it could never be detached)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_subscription_lambda_note.facts.json b/tests/fixtures/verdicts/verdict_subscription_lambda_note.facts.json new file mode 100644 index 00000000..e1caee3c --- /dev/null +++ b/tests/fixtures/verdicts/verdict_subscription_lambda_note.facts.json @@ -0,0 +1,21 @@ +{ + "ownir_version": 0, + "module": "SubscriptionLambdaNote", + "_doc": [ + "The inline-lambda note on the INJECTED plain-subscription wording. The", + "corpora reach the note only on the static-source branch, so the injected one", + "(a different sentence, a different severity tier) had no control. `Unknown`", + "is deliberately unregistered: a registered source_type would route through", + "R5 into the OWN014 captive wording instead." + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + {"event": "dep.Changed", "handler": "", "line": 7, + "source": "injected", "source_type": "Unknown", "lambda": true} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_subscription_lambda_note.verdicts.json b/tests/fixtures/verdicts/verdict_subscription_lambda_note.verdicts.json new file mode 100644 index 00000000..237da134 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_subscription_lambda_note.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Vm.cs", + "line": 7, + "code": "OWN001", + "component": "Vm", + "event": "dep.Changed", + "handler": "", + "message": "event 'dep.Changed' is subscribed (handler '') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak — and being an inline lambda it has no '-=' handle, so it could never be detached)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_unknown_line_guards.facts.json b/tests/fixtures/verdicts/verdict_unknown_line_guards.facts.json new file mode 100644 index 00000000..51faa6de --- /dev/null +++ b/tests/fixtures/verdicts/verdict_unknown_line_guards.facts.json @@ -0,0 +1,42 @@ +{ + "ownir_version": 0, + "module": "UnknownLineGuards", + "_doc": [ + "BR-V5's line guards, one per slice builder, each on a record that IS a", + "verdict but cannot anchor its slice. `s` is used after disposal but its", + "acquire line is unknown, so the flow-local origin hop has nowhere to point;", + "`bus.E` is a proven captive whose SUBSCRIBE line is unknown, so the escape", + "slice is never started; the `Widget` effect fires at a known line but the", + "identity it depends on is minted at an unknown one, so the second hop is", + "missing. Every one of the three emits its verdict and NO slice — a fake", + "step at line 0 would be a well-formed coordinate pointing nowhere." + ], + "services": [ + {"name": "Bus", "lifetime": "singleton", "file": "reg.cs", "line": 3, "deps": []} + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + {"event": "bus.E", "handler": "OnE", "line": 0, + "source": "injected", "source_type": "Bus"} + ] + } + ], + "functions": [ + { + "name": "Guards.UsedAfterDispose", + "file": "G.cs", + "body": [ + {"op": "acquire", "var": "s", "line": 0}, + {"op": "release", "var": "s", "line": 2}, + {"op": "use", "var": "s", "line": 3} + ] + } + ], + "effects": [ + {"component": "Widget", "file": "Widget.tsx", "line": 5, "io": true, "deps": ["opts"], + "bindings": [{"name": "opts", "init": "object", "refs": [], "line": 0}]} + ] +} diff --git a/tests/fixtures/verdicts/verdict_unknown_line_guards.verdicts.json b/tests/fixtures/verdicts/verdict_unknown_line_guards.verdicts.json new file mode 100644 index 00000000..adaf44c2 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_unknown_line_guards.verdicts.json @@ -0,0 +1,53 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "G.cs", + "line": 0, + "code": "OWN002", + "component": "Guards.UsedAfterDispose", + "event": "s", + "handler": "", + "message": "IDisposable local 's' is used after it is disposed", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 0, + "code": "OWN014", + "component": "Vm", + "event": "bus.E", + "handler": "OnE", + "message": "event 'bus.E' is subscribed (handler 'OnE') to 'Bus' — a DI singleton (application-lifetime) service that outlives 'Vm'; the strong subscription promotes 'Vm' to the source's lifetime, so it can never be collected — a captive/region escape (leak, no release path)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Widget.tsx", + "line": 5, + "code": "EFF001", + "component": "Widget", + "event": "opts", + "handler": "", + "message": "effect re-runs on every render: dependency 'opts' is an object literal created in render scope, so its identity changes on every render; the effect performs IO, which can become a request storm — stabilise 'opts' with useMemo/useCallback (or move it out of render)", + "kind": "react effect", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/test_checkpoint_status.py b/tests/test_checkpoint_status.py index d317dfd1..e195bb46 100644 --- a/tests/test_checkpoint_status.py +++ b/tests/test_checkpoint_status.py @@ -2,9 +2,11 @@ """Gate: the generated checkpoint status fragments equal their projection, and every recorded mutation campaign can still be replayed. -`docs/generated/p022-cp4-*.md` and `docs/generated/p022-shadow-*.md` are -rendered from the evidence in the tree by `scripts/render_checkpoint_status.py` -(the verdict ledger census through `tests/verdict_census.py`; the step-7a +`docs/generated/p022-cp4-*.md`, `docs/generated/p022-cp5-*.md` and +`docs/generated/p022-shadow-*.md` are rendered from the evidence in the tree by +`scripts/render_checkpoint_status.py` (the verdict ledger census through +`tests/verdict_census.py`; the cp5 surface inventory through +`tests/verdict_surface_inventory.py`; the step-7a census through `tests/shadow_census.py`; every recorded mutation campaign through `scripts/mutate_campaign.py`). This module runs its `--check` in-process, so a change to the evidence without regenerating the fragments — or @@ -41,6 +43,9 @@ from render_checkpoint_status import ( # noqa: E402 CAMPAIGN, CENSUS_MD, + CP5_CAMPAIGNS, + CP5_MUTATIONS_MD, + INVENTORY_MD, MUTATIONS_MD, SHADOW_CAMPAIGNS, SHADOW_CENSUS_MD, @@ -51,8 +56,9 @@ EVIDENCE = os.path.join(ROOT, "docs", "evidence") # Every campaign definition in the tree, gated for replayability. A campaign # nobody listed is a campaign nobody re-anchors. -DEFINITIONS = (CAMPAIGN, *(os.path.join(EVIDENCE, f"{campaign}.json") - for _, campaign in SHADOW_CAMPAIGNS)) +DEFINITIONS = (CAMPAIGN, + *(os.path.join(EVIDENCE, f"{campaign}.json") + for _, campaign in (*CP5_CAMPAIGNS, *SHADOW_CAMPAIGNS))) def _anchors() -> list[str]: @@ -81,8 +87,9 @@ def run() -> int: f"tree; re-anchor it (the recorded result stays valid for the commit it names)") if problems or anchors: return 1 - print(f"checkpoint status fragments OK: {CENSUS_MD}, {MUTATIONS_MD}, {SHADOW_CENSUS_MD}, " - f"{SHADOW_MUTATIONS_MD} in sync with the evidence; " + print(f"checkpoint status fragments OK: {CENSUS_MD}, {INVENTORY_MD}, {MUTATIONS_MD}, " + f"{CP5_MUTATIONS_MD}, {SHADOW_CENSUS_MD}, {SHADOW_MUTATIONS_MD} in sync with " + f"the evidence; " f"{len(DEFINITIONS)} campaign definitions still anchor") return 0 diff --git a/tests/test_unreachable_branch_probe.py b/tests/test_unreachable_branch_probe.py new file mode 100644 index 00000000..1ce308c7 --- /dev/null +++ b/tests/test_unreachable_branch_probe.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""The oracle probe for branches no facts document can reach (P-022 #259 cp5). + +A handful of BR-V4 wordings are unreachable end to end. The routing table never +mints a handle that would take them, or the analysis never reports a verdict +that would select them, so no facts document — synthetic or otherwise — +produces one. They are still the reference's wordings, and the port carries +controls for each; the question this module answers is **where those controls' +expected text comes from**. + +Not from reading `ownlang/ownir.py`. From the reference itself: `check_facts` +is run with its lowering and its core substituted, so the oracle can be asked +about a state its own inputs cannot construct. That is a *probe*, not a +fixture, and the distinction is worth keeping straight: + +* it proves the reference's wording for a given handle record and diagnostic; +* it proves **nothing** about reachability, ordering, or the pipeline around + the branch — the substitution removes exactly those; +* so it cannot replace a golden anywhere a golden is possible, and it is used + only where one is not. + +`tests/fixtures/unreachable_branches.json` is the recorded answer. The Rust +controls read it (`own-bridge/src/verdict.rs`, `own-analysis/src/effect.rs`) +rather than carrying their own copy of the text, so "the oracle said so" is a +re-runnable fact and the two sides cannot drift into agreeing with each other +instead of with Python. + +Run: python tests/test_unreachable_branch_probe.py (verify) + python tests/test_unreachable_branch_probe.py --write (re-probe) + python tests/run_tests.py (runs it in the suite) +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import ownlang.__main__ as driver +import ownlang.ownir as ownir +from ownlang.diagnostics import Diagnostic +from ownlang.effects import EffectStorm + +HERE = os.path.dirname(os.path.abspath(__file__)) +FIXTURE = os.path.join(HERE, "fixtures", "unreachable_branches.json") +# Bump when a probe is added, removed or re-shaped; the Rust controls read the +# same file and assert the same version. +PROBE_VERSION = 1 + + +def _probe(handles: dict[str, dict[str, Any]], diags: list[Diagnostic]) -> list[str]: + """Run `check_facts` over a hand-built lowering and a hand-built core + result, and return the messages it synthesized. + + Both seams are restored in a `finally`, so a failure here cannot leak a + patched module into another test in the same process. + """ + original_to_module, original_check = ownir.to_module, driver.check_module + ownir.to_module = lambda facts, notes=None, advisories=None: (None, handles) + driver.check_module = lambda module: diags + try: + return [f.message for f in ownir.check_facts({"module": "Probe"})] + finally: + ownir.to_module, driver.check_module = original_to_module, original_check + + +def _record(**over: Any) -> dict[str, Any]: + return {"component": "C.M", "file": "A.cs", **over} + + +def probe_all() -> dict[str, str]: + """Every unreachable wording, keyed by the control that carries it.""" + out: dict[str, str] = {} + + # The two flow-local fallbacks: a code with no wording of its own keeps the + # CORE diagnostic's message after a colon. Unreachable because the nine-op + # OwnIR flow vocabulary raises only codes that HAVE a wording. + handles = { + "loc_0": _record(resource="flow-local", event="s", line=4, pool=False, + ever_released=False), + "loc_1": _record(resource="flow-local", event="s", line=4, pool=True, + ever_released=False), + } + diags = [Diagnostic("OWN005", "moved 's' at A.cs:9", 9, subject=f"{h}#4") + for h in ("loc_0", "loc_1")] + plain, pooled = _probe(handles, diags) + out["flow_local_fallback_plain"] = plain + out["flow_local_fallback_pooled"] = pooled + + # The DI lifetime phrases the engine cannot select: `transient` is the + # shortest region, so no subscriber it could outlive exists, and a lifetime + # outside the three never reaches `di_source_life` at all. + handles = { + f"cap_{i}": _record(file="Vm.cs", component="Vm", event="src.E", handler="OnE", + line=7, source="injected", source_type="Src", + di_source_life=life) + for i, life in enumerate(("transient", "gremlin")) + } + diags = [Diagnostic("OWN014", "escape", 7, subject=f"cap_{i}#7") for i in range(2)] + transient, unknown = _probe(handles, diags) + out["own014_di_transient"] = transient + out["own014_di_unknown_lifetime"] = unknown + + # The capture route's named-source origin: routing R3 mints a handle only + # for a source with a declared capture region, and `static` is the only one. + handles = {"cap_0": _record(file="Vm.cs", component="Vm", resource="capture", + event="svc.E", handler="OnE", line=9, + source="container")} + diags = [Diagnostic("OWN014", "escape", 9, subject="cap_0#9")] + (named,) = _probe(handles, diags) + out["own014_capture_named_source"] = named + + # The effect message's `via` clause, guarded on a chain longer than one hop. + # Unreachable because a storm only words "derives from" when it walked at + # least one reference, which makes the chain two entries or more. No + # substitution needed: the dataclass is the reference's own value. + out["eff001_single_hop_chain_has_no_via"] = EffectStorm( + component="W", dep="cfg", origin="opts", origin_kind="object", + file="W.tsx", line=5, decl_line=2, path=("opts",)).message + out["eff001_multi_hop_chain_has_via"] = EffectStorm( + component="W", dep="cfg", origin="opts", origin_kind="object", + file="W.tsx", line=5, decl_line=2, path=("cfg", "opts")).message + return out + + +def _document() -> dict[str, Any]: + return { + "comment": ("GENERATED by tests/test_unreachable_branch_probe.py --write; do not " + "edit. The REFERENCE's own message text for branches no facts document " + "can reach, obtained by running check_facts with its lowering and core " + "substituted. The Rust controls read this file instead of carrying " + "their own copy of the text."), + "probe_version": PROBE_VERSION, + "messages": probe_all(), + } + + +def render() -> str: + return json.dumps(_document(), indent=2, ensure_ascii=False) + "\n" + + +def run() -> int: + fails: list[str] = [] + rendered = render() + if render() != rendered: + fails.append("the probe is not deterministic") + if not os.path.exists(FIXTURE): + fails.append("fixture missing; re-probe with " + "'python tests/test_unreachable_branch_probe.py --write'") + else: + with open(FIXTURE, encoding="utf-8") as f: + committed = f.read() + if committed != rendered: + fails.append("fixture is stale — the reference's wording for an unreachable " + "branch changed (a BR-V4 contract change), or the probe did; " + "re-probe with 'python tests/test_unreachable_branch_probe.py " + "--write' and re-run the Rust side (cd rust && cargo test)") + # A probe that reported nothing would pass every check above. + if not fails and not json.loads(rendered)["messages"]: + fails.append("the probe produced no messages") + if fails: + for f_ in fails: + print(f"FAIL: unreachable-branch probe {f_}") + return 1 + print(f"unreachable-branch probe OK: {len(json.loads(rendered)['messages'])} " + f"reference wordings recorded") + return 0 + + +def write() -> int: + with open(FIXTURE, "w", encoding="utf-8") as f: + f.write(render()) + print(f"wrote {FIXTURE}") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + raise SystemExit(write()) + raise SystemExit(run()) diff --git a/tests/test_verdict_render_fixtures.py b/tests/test_verdict_render_fixtures.py new file mode 100644 index 00000000..554f153a --- /dev/null +++ b/tests/test_verdict_render_fixtures.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""BR-V9 fixtures (P-022 #259 checkpoint 5.3) — the bridge's rendered surfaces. + +Each golden `tests/fixtures/verdict_renders/.renders.json` pins what a +consumer SEES for one facts document: `ownir.render_finding` in the human, +GitHub-annotation and MSBuild formats (plus one format it does not know, so the +fallback is rendered rather than assumed), each at both host severities, and +`ownir.build_sarif` as the one SARIF 2.1.0 log per run, also at both. The +emitter is `ownlang/renders.py`, an observer that calls the production +renderers and records what they returned. + +This is the surface checkpoint 4 carried as "deferred" and checkpoint 5.0's +inventory reported as having no fixture family at all. The Rust `own-bridge` +replays every case with zero Python and must reproduce the bytes +(`rust/crates/own-bridge/tests/renders.rs`). + +Cases are listed, never swept: one exists to exercise a BR-V9 rule, so the +manifest names the ledger rows each one pins and the surface inventory reports +a row nobody pins. Rendering the whole verdict corpus at two severities would +freeze megabytes to prove less. + +Beyond the goldens, two properties are asserted here because they are claims +about the surface rather than about one case: + +* **determinism** — every case renders byte-identically twice; +* **no `subject` leaks** — `ownir.Finding` carries no diagnostic `subject`, so + no rendered surface can serialize one. The checkpoint-4 note promised to + re-check that once the bridge grew render and SARIF paths; this checks the + rendered BYTES rather than restating the promise. + +* Python is authoritative: `python tests/test_verdict_render_fixtures.py --write` + regenerates every golden. + +Run: python tests/test_verdict_render_fixtures.py (verify) + python tests/test_verdict_render_fixtures.py --write (regenerate) + python tests/run_tests.py (runs it in the suite) +""" + +from __future__ import annotations + +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from verdict_render_census import Plan, goldens_on_disk, plan + +from ownlang.renders import RENDERS_VERSION, render_renders + +# Every `own_diagnostics` subject would look like this if one ever reached a +# rendered surface: the bridge's own `subject`-free `Finding` is what makes it +# impossible, and this is the string that would give it away. +SUBJECT_MARKERS = ('"subject"', "'subject'") + + +def _plan() -> Plan: + p = plan() + problems = list(p.problems) + if p.renders_version != RENDERS_VERSION: + problems.append(f"manifest renders_version {p.renders_version!r} != " + f"emitter RENDERS_VERSION {RENDERS_VERSION}") + return Plan(p.cases, p.renders_version, tuple(problems)) + + +def _project(facts_path: str) -> str: + with open(facts_path, encoding="utf-8") as f: + facts = json.load(f) + return render_renders(facts) + + +def run() -> int: + p = _plan() + fails = list(p.problems) + if not p.cases and not fails: + fails.append("no cases planned (an empty ledger proves nothing)") + for case in sorted(p.cases): + rendered = _project(p.facts_path(case)) + if _project(p.facts_path(case)) != rendered: + fails.append(f"{case}: projection is non-deterministic") + continue + for marker in SUBJECT_MARKERS: + if marker in rendered: + fails.append(f"{case}: a rendered surface carries {marker} — the bridge's " + f"Finding has no diagnostic subject and no output may invent " + f"one (the checkpoint-4 subject tail, re-checked here)") + golden = p.golden_path(case) + if not os.path.exists(golden): + fails.append(f"{case}: golden missing; regenerate with " + f"'python tests/test_verdict_render_fixtures.py --write'") + continue + with open(golden, encoding="utf-8") as f: + actual = f.read() + if actual != rendered: + fails.append(f"{case}: golden is stale (a rendering changed); regenerate with " + f"'python tests/test_verdict_render_fixtures.py --write' and " + f"re-run the Rust side (cd rust && cargo test)") + for orphan in sorted(goldens_on_disk() - set(p.cases)): + fails.append(f"{orphan}: orphaned golden (not a planned case); remove it or " + f"restore the case (manifest/facts)") + if fails: + for f_ in fails: + print(f"FAIL: verdict render fixture {f_}") + return 1 + print(f"verdict renders (BR-V9) fixtures OK: {len(p.cases)} cases verified in sync") + return 0 + + +def write() -> int: + """Regenerate every planned case. Regeneration never accepts a shrunken or + inconsistent ledger (the same rule the other fixture families hold).""" + p = _plan() + if p.problems: + for problem in p.problems: + print(f"ERROR: {problem}") + return 1 + for case in sorted(p.cases): + out = p.golden_path(case) + with open(out, "w", encoding="utf-8") as f: + f.write(_project(p.facts_path(case))) + print(f"wrote {out}") + for orphan in sorted(goldens_on_disk() - set(p.cases)): + path = p.golden_path(orphan) + os.remove(path) + print(f"removed orphaned {path}") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + raise SystemExit(write()) + raise SystemExit(run()) diff --git a/tests/verdict_render_census.py b/tests/verdict_render_census.py new file mode 100644 index 00000000..606bd576 --- /dev/null +++ b/tests/verdict_render_census.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""The rendered-surface ledger, interpreted once (P-022 #259 checkpoint 5.3). + +`tests/fixtures/verdict_renders/manifest.json` plus the `.renders.json` +goldens are checkpoint 5.3's evidence. This module is the ONE place that reads +them, so the fixture harness (`tests/test_verdict_render_fixtures.py`), the +surface inventory (`tests/verdict_surface_inventory.py`) and the Rust replay's +Python-side counterpart cannot disagree about what the tree contains. + +Unlike the verdict family, nothing here is swept: a rendered-surface case +exists to exercise a BR-V9 rule, so it is listed with the rules it pins and a +rule with no case is a hole the inventory reports rather than a case nobody +noticed. Every case carries its own facts document beside the manifest — the +verdict corpus is not re-rendered wholesale, because rendering 79 documents at +two severities would freeze megabytes to prove what a handful of targeted +documents prove better. + +Pure: no `ownlang` import and no side effects. Held to `mypy --strict`. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass + +HERE = os.path.dirname(os.path.abspath(__file__)) +FIXDIR = os.path.join(HERE, "fixtures", "verdict_renders") +MANIFEST = os.path.join(FIXDIR, "manifest.json") +FACTS_SUFFIX = ".facts.json" +GOLDEN_SUFFIX = ".renders.json" + + +@dataclass(frozen=True) +class Case: + name: str + rules: tuple[str, ...] + #: the BR-V9 ledger rows this case is the control for. + pins: tuple[str, ...] + + +@dataclass(frozen=True) +class Plan: + cases: dict[str, Case] + renders_version: int | None + problems: tuple[str, ...] + + def facts_path(self, name: str) -> str: + return os.path.join(FIXDIR, f"{name}{FACTS_SUFFIX}") + + def golden_path(self, name: str) -> str: + return os.path.join(FIXDIR, f"{name}{GOLDEN_SUFFIX}") + + +def _stems(suffix: str) -> set[str]: + if not os.path.isdir(FIXDIR): + return set() + return {n[: -len(suffix)] for n in os.listdir(FIXDIR) if n.endswith(suffix)} + + +def plan(manifest_path: str = MANIFEST) -> Plan: + """The case plan, with every ledger problem found on the way: a case with no + facts, a facts file no case lists, a duplicate name, a case pinning nothing.""" + problems: list[str] = [] + if not os.path.exists(manifest_path): + return Plan({}, None, (f"manifest missing: {manifest_path}",)) + with open(manifest_path, encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + return Plan({}, None, (f"manifest is not a JSON object: {manifest_path}",)) + raw_version = data.get("renders_version") + version: int | None = None + if isinstance(raw_version, int) and not isinstance(raw_version, bool): + version = raw_version + else: + problems.append(f"manifest renders_version must be an integer, got {raw_version!r}") + cases: dict[str, Case] = {} + raw_cases = data.get("cases", []) + if not isinstance(raw_cases, list): + problems.append("manifest cases must be an array") + raw_cases = [] + for c in raw_cases: + if not isinstance(c, dict): + problems.append(f"manifest case must be an object: {c!r}") + continue + name = c.get("name") + rules, pins = c.get("rules"), c.get("pins") + if not (isinstance(name, str) and name): + problems.append(f"manifest case without a name: {c!r}") + continue + if not (isinstance(rules, list) and rules + and all(isinstance(r, str) and r for r in rules)): + problems.append(f"manifest case '{name}': 'rules' must be a non-empty array " + f"of non-empty strings") + rules = [] + if not (isinstance(pins, list) and pins + and all(isinstance(p, str) and p for p in pins)): + problems.append(f"manifest case '{name}': 'pins' must name at least one BR-V9 " + f"ledger row — a rendered case that pins nothing is a golden " + f"nobody can read a claim off") + pins = [] + if name in cases: + problems.append(f"manifest lists case '{name}' twice") + cases[name] = Case(name, tuple(rules), tuple(pins)) + on_disk = _stems(FACTS_SUFFIX) + for missing in sorted(set(cases) - on_disk): + problems.append(f"case '{missing}' has no facts file ({missing}{FACTS_SUFFIX})") + for unlisted in sorted(on_disk - set(cases)): + problems.append(f"'{unlisted}{FACTS_SUFFIX}' is not in manifest.json — add the case " + f"to the ledger (name, rules, pins)") + return Plan(cases, version, tuple(problems)) + + +def goldens_on_disk() -> set[str]: + return _stems(GOLDEN_SUFFIX) + + +def pinned_rows(p: Plan | None = None) -> dict[str, tuple[str, ...]]: + """BR-V9 ledger row -> the cases that pin it, from the manifest alone. The + ledger of rows lives in `verdict_surface_inventory`; this is the other half + of the join, and keeping them apart is what lets the inventory report a row + nobody pins.""" + if p is None: + p = plan() + out: dict[str, list[str]] = {} + for case in p.cases.values(): + for row in case.pins: + out.setdefault(row, []).append(case.name) + return {row: tuple(sorted(names)) for row, names in out.items()} + + +class RenderCensusError(Exception): + """The tree is not in a state a rendered-surface census may be taken over.""" + + def __init__(self, problems: list[str]) -> None: + super().__init__("; ".join(problems)) + self.problems = tuple(problems) + + +@dataclass(frozen=True) +class RenderCensus: + """What the rendered-surface family contains, computed from the tree. + + `refusals` counts the cases whose golden is a bridge refusal (nothing to + render); `rendered_lines` is every line the three line-per-finding surfaces + emit across both host severities, and `sarif_results` every SARIF result — + the two numbers that say how much text the replay compares byte for byte. + """ + + cases: int + refusals: int + rendered_lines: int + sarif_results: int + pinned_rows: int + + +def compute_render_census(p: Plan | None = None) -> RenderCensus: + """Count the family. Raises `RenderCensusError` on any ledger problem or a + missing/orphaned/malformed golden — a census over a broken tree is the + stale number this module exists to prevent.""" + if p is None: + p = plan() + problems = list(p.problems) + on_disk = goldens_on_disk() + for missing in sorted(set(p.cases) - on_disk): + problems.append(f"{missing}: golden missing") + for orphan in sorted(on_disk - set(p.cases)): + problems.append(f"{orphan}: orphaned golden (not a planned case)") + if not p.cases and not problems: + problems.append("no cases planned") + if problems: + raise RenderCensusError(problems) + + refusals = lines = results = 0 + for name in sorted(p.cases): + with open(p.golden_path(name), encoding="utf-8") as f: + doc = json.load(f) + if not isinstance(doc, dict): + problems.append(f"{name}: golden is not a JSON object") + continue + if doc.get("renders_version") != p.renders_version: + problems.append(f"{name}: golden renders_version " + f"{doc.get('renders_version')!r} != manifest " + f"{p.renders_version!r}") + continue + if isinstance(doc.get("error"), str): + refusals += 1 + continue + for surface in ("human", "github", "msbuild", "unknown-format"): + per_severity = doc.get(surface) + if not isinstance(per_severity, dict): + problems.append(f"{name}: golden has no '{surface}' surface") + continue + lines += sum(len(v) for v in per_severity.values() if isinstance(v, list)) + sarif = doc.get("sarif") + if not isinstance(sarif, dict): + problems.append(f"{name}: golden has no 'sarif' surface") + continue + for log in sarif.values(): + runs = log.get("runs", []) if isinstance(log, dict) else [] + for run in runs: + results += len(run.get("results", [])) + if problems: + raise RenderCensusError(problems) + return RenderCensus(len(p.cases), refusals, lines, results, len(pinned_rows(p))) diff --git a/tests/verdict_surface_inventory.py b/tests/verdict_surface_inventory.py new file mode 100644 index 00000000..bcbe54d7 --- /dev/null +++ b/tests/verdict_surface_inventory.py @@ -0,0 +1,695 @@ +#!/usr/bin/env python3 +"""The Layer 3 **surface** inventory (P-022 #259 checkpoint 5.0). + +`tests/verdict_census.py` counts the frozen ledger — how many goldens, how many +findings, how many are replayed. This module asks the question checkpoint 5 +actually needs answered: *which* of the surfaces cp5 must prove do those +goldens already exercise, and which are not reached at all. + +Three ledgers, each a list of branches read off `ownlang/ownir.py` (the +reference), each branch matched against the committed goldens so the coverage +column is **computed from the tree**, never typed: + +* **BR-V4 — message synthesis.** Every wording branch of the matrix in + `check_facts`, plus the messages the bridge does *not* synthesize: the DI and + effect verdicts carry `di.py`/`effects.py`'s own `message` property, and two + flow-local fallbacks interpolate the **core diagnostic's** message. Each + branch declares its `source`, which is the answer to "who owns this string" — + the question that decides whether cp5 ports a bridge wording, an analysis + wording, or a core-diagnostic message. +* **BR-V5 — evidence slices.** Every `related`/`flow` family, by field, code and + step count, plus the two degradation rules (a step with `line < 1` is + omitted; a slice shorter than two steps is dropped) as their own rows. +* **BR-V9 — rendered surfaces.** The `render_finding`/`build_sarif` branches. + Coverage is the join of the row ledger below with the `pins` each case of the + `tests/fixtures/verdict_renders/` family declares (read through + `verdict_render_census`). The two halves are deliberately apart: a row nobody + pins is reported here, and a case pinning a row this ledger does not know is + a problem rather than a silent extra. + +Self-policing, in the same spirit as the census: every golden finding must +match **exactly one** BR-V4 branch and every non-empty slice exactly one BR-V5 +family. Zero matches means the ledger has a hole; two means two branches are +indistinguishable on the surface — both are `problems`, and both fail the gate +rather than quietly rounding the inventory down. + +Pure: no `ownlang` import and no side effects — it reads what is committed. +Held to `mypy --strict` (see `files` in pyproject.toml). +""" + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass + +from verdict_census import FIXDIR, GOLDEN_SUFFIX, Plan, plan +from verdict_render_census import pinned_rows +from verdict_render_census import plan as render_plan + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# --- message sources ------------------------------------------------------ +# Who owns the string. The distinction is the whole point of the cp5.0 +# inventory: a bridge wording is ported into `own-bridge`, an analysis wording +# belongs to `own-analysis` (the finder that already owns the verdict), and a +# core-diagnostic message is the one thing this core does not have at all +# (it carries each code's TITLE where the reference carries a sentence). +BRIDGE = "bridge" +CORE_ANALYSIS = "core-analysis" +CORE_DIAGNOSTIC = "core-diagnostic" +PROTOCOL = "bridge-protocol" +SOURCES = (BRIDGE, CORE_ANALYSIS, CORE_DIAGNOSTIC, PROTOCOL) + +# The inline-lambda note, appended verbatim wherever the record's `lambda` is +# true (BR-V4). Declared once: three branches share it. +LAMBDA_NOTE = (" — and being an inline lambda it has no '-=' handle, so it " + "could never be detached") +# A quoted identifier interpolated into a wording. Non-greedy: every template +# below anchors it with a literal tail, so the shortest match is the right one. +N = "(?:.*?)" + + +@dataclass(frozen=True) +class Branch: + """One declared wording branch, and how a golden finding is recognised as + having taken it. + + Recognition is by `(code, kind, message)` — everything a serialized + `Finding` carries about its wording. Two branches of the matrix are + therefore ONE row here, and deliberately so: the flow-local "never + returned" wording and the `pool` token wording are the same sentence on the + same `kind`, and the reference emits `handler=""` for both, so nothing in + the Layer 3 document separates them. That is a property of the surface, not + a shortcut — a port that reached the sentence by the other branch would + produce a byte-identical golden — and it is recorded on the row rather than + papered over with a discriminator that does not exist. `handler_empty` + stays available for a branch that genuinely needs it; none does today. + """ + + id: str + rule: str + source: str + what: str + pattern: str + codes: tuple[str, ...] = () + kinds: tuple[str, ...] = () + handler_empty: bool | None = None + #: why this row is expected to read zero. A row at zero WITHOUT one is a + #: missing control; a row at zero WITH one is a recorded disposition, and + #: the note has to name what pins the branch instead. + note: str = "" + + def matches(self, f: dict[str, object]) -> bool: + code, kind = f.get("code"), f.get("kind") + handler, message = f.get("handler"), f.get("message") + if self.codes and code not in self.codes: + return False + if self.kinds and kind not in self.kinds: + return False + if self.handler_empty is not None and (handler == "") != self.handler_empty: + return False + return isinstance(message, str) and re.fullmatch(self.pattern, message, + re.DOTALL) is not None + + +#: The OBL family's disposition — see `Branch.note`. +PROTOCOL_IS_4B = ( + "#259 row 4b (the OBL analysis is not ported); outside cp5 by declaration") +#: Dispositions for the rows a facts document cannot reach — see `Branch.note`. +UNREACHABLE_DI_PHRASE = ( + "unreachable from any facts document: `transient` is the shortest region, so no " + "subscriber it could outlive exists, and an unrecognised lifetime never reaches " + "`di_source_life` (the DI life map admits only the three). Pinned by " + "`verdict::tests::every_di_lifetime_phrase_is_pinned_including_the_unreachable_two`, " + "whose expected text is not written in the test: it is read from the recorded " + "probe `tests/fixtures/unreachable_branches.json`" +) +UNREACHABLE_CAPTURE_PHRASE = ( + "unreachable from any facts document: routing R3 mints a handle only for a source " + "with a declared capture region, and `static` is the only entry in that table. Pinned " + "by the `verdict::tests` control named for it, whose expected text is not written in " + "the test: it is read from the recorded probe " + "`tests/fixtures/unreachable_branches.json`" +) +#: The disposition of the two fallback rows: see `Branch.note`. +UNIT_PINNED = ( + "unreachable from any facts document (the nine-op flow vocabulary raises only codes " + "that HAVE a wording); pinned by a `verdict::tests` control driven through " + "`map_core`, whose expected text is read from the recorded probe " + "`tests/fixtures/unreachable_branches.json`. The core message it " + "interpolates is checkpoint 5.2's" +) + + +def _flow_local(bid: str, code: str, kind: str, what: str, tail: str, + source: str = BRIDGE, note: str = "") -> Branch: + noun = "pooled buffer" if kind == "pooled buffer" else "IDisposable local" + return Branch(bid, "BR-V4", source, what, + rf"{re.escape(noun)} '{N}'{tail}", (code,), (kind,), note=note) + + +# --- BR-V4: the message matrix ------------------------------------------- +MESSAGE_BRANCHES: tuple[Branch, ...] = ( + # flow-local OWN001, split on `ever_released` and on `pool`. + _flow_local("flowlocal_own001_never", "OWN001", "disposable", + "flow-local OWN001, never released", r" is never disposed \(leak\)"), + _flow_local("flowlocal_own001_partial", "OWN001", "disposable", + "flow-local OWN001, released on some path", + r" may not be disposed on every path \(leak\)"), + _flow_local("flowlocal_own001_pool_partial", "OWN001", "pooled buffer", + "flow-local OWN001 on a pooled buffer, returned on some path", + r" may not be returned to the pool on every path \(leak\)"), + # flow-local use/release codes, per pool split. + _flow_local("flowlocal_own002", "OWN002", "disposable", "flow-local use-after-dispose", + r" is used after it is disposed"), + _flow_local("flowlocal_own003", "OWN003", "disposable", "flow-local double dispose", + r" is disposed more than once"), + _flow_local("flowlocal_own009", "OWN009", "disposable", + "flow-local maybe-use-after-dispose", + r" may be used after disposal on some path"), + _flow_local("flowlocal_own002_pool", "OWN002", "pooled buffer", + "flow-local use-after-return", r" is used after it is returned to the pool"), + _flow_local("flowlocal_own003_pool", "OWN003", "pooled buffer", + "flow-local double return", r" is returned to the pool more than once"), + _flow_local("flowlocal_own009_pool", "OWN009", "pooled buffer", + "flow-local maybe-use-after-return", + r" may be used after being returned on some path"), + # the two fallbacks: a flow-local code with no wording of its own keeps the + # CORE diagnostic's message verbatim after a colon. + _flow_local("flowlocal_fallback", "", "disposable", + "flow-local fallback: the core message, verbatim", r": .*", + CORE_DIAGNOSTIC, UNIT_PINNED), + _flow_local("flowlocal_fallback_pool", "", "pooled buffer", + "flow-local pooled fallback: the core message, verbatim", + r": .*", CORE_DIAGNOSTIC, UNIT_PINNED), + Branch("own025_view", "BR-V4", BRIDGE, "OWN025 pooled-view wording", + rf"pooled buffer '{N}' is viewed at its full length, past the logical " + rf"length it was rented for \(over-read / over-clear\)", + ("OWN025",), ("pooled buffer",)), + # OWN014, DI-sourced: the `nice` lifetime phrase, and the lambda note. + *(Branch(f"own014_di_{life}", "BR-V4", BRIDGE, + f"OWN014 captive, source registered {label}", + rf"event '{N}' is subscribed \(handler '{N}'\) to '{N}' — {re.escape(nice)} " + rf"that outlives '{N}'; the strong subscription promotes '{N}' to the " + rf"source's lifetime, so it can never be collected — a captive/region " + rf"escape \(leak, no release path\)", + (), ("subscription token",), note=note) + for life, label, nice, note in ( + ("singleton", "singleton", "a DI singleton (application-lifetime) service", ""), + ("scoped", "scoped", "a DI scoped service", ""), + ("transient", "transient", "a DI transient service", UNREACHABLE_DI_PHRASE))), + Branch("own014_di_unknown_life", "BR-V4", BRIDGE, + "OWN014 captive, source lifetime outside the three known ones", + rf"event '{N}' is subscribed \(handler '{N}'\) to '{N}' — a DI (?!singleton " + rf"\(application-lifetime\) |scoped |transient ){N} service that outlives " + rf"'{N}'; the strong subscription promotes '{N}' to the source's lifetime, so " + rf"it can never be collected — a captive/region escape \(leak, no release path\)", + (), ("subscription token",), note=UNREACHABLE_DI_PHRASE), + Branch("own014_di_lambda", "BR-V4", BRIDGE, + "OWN014 captive on an inline lambda handler (the no-'-=' note)", + rf"event '{N}' is subscribed \(handler '{N}'\) to '{N}' — a DI {N} that " + rf"outlives '{N}'; the strong subscription promotes '{N}' to the source's " + rf"lifetime, so it can never be collected — a captive/region escape " + rf"\(leak, no release path{re.escape(LAMBDA_NOTE)}\)", + (), ("subscription token",)), + # OWN014, capture routing: the static vs named-source origin, and the note. + Branch("own014_capture_static", "BR-V4", BRIDGE, "OWN014 capture of a static source", + rf"event '{N}' is subscribed \(handler '{N}'\) to a static \(process-lived\) " + rf"event source that outlives '{N}'; the strong subscription promotes '{N}' to " + rf"the source's lifetime, so it can never be collected — a region escape " + rf"\(leak, no release path\)", (), ("subscription token",)), + Branch("own014_capture_named", "BR-V4", BRIDGE, + "OWN014 capture of a named longer-lived source", + rf"event '{N}' is subscribed \(handler '{N}'\) to a longer-lived source " + rf"\('{N}'\) that outlives '{N}'; the strong subscription promotes '{N}' to the " + rf"source's lifetime, so it can never be collected — a region escape " + rf"\(leak, no release path\)", (), ("subscription token",), + note=UNREACHABLE_CAPTURE_PHRASE), + Branch("own014_capture_lambda", "BR-V4", BRIDGE, + "OWN014 capture on an inline lambda handler (the no-'-=' note)", + rf"event '{N}' is subscribed \(handler '{N}'\) to (?:a static \(process-lived\) " + rf"event source|a longer-lived source \('{N}'\)) that outlives '{N}'; the strong " + rf"subscription promotes '{N}' to the source's lifetime, so it can never be " + rf"collected — a region escape \(leak, no release path" + rf"{re.escape(LAMBDA_NOTE)}\)", (), ("subscription token",)), + # the token kinds. + Branch("token_timer", "BR-V4", BRIDGE, "timer wording", + rf"timer '{N}' \(handler '{N}'\) is started but never stopped or detached — " + rf"the running timer keeps '{N}' alive \(leak\)", (), ("timer",)), + Branch("token_disposable_typed", "BR-V4", BRIDGE, "disposable field, `type` present", + rf"IDisposable field '{N}' \(type '{N}'\) is never disposed — its owner " + rf"'{N}' leaks it \(leak\)", (), ("disposable field",)), + Branch("token_disposable_untyped", "BR-V4", BRIDGE, "disposable field, no `type`", + rf"IDisposable field '{N}' is never disposed — its owner '{N}' leaks it " + rf"\(leak\)", (), ("disposable field",)), + Branch("token_local_disposable_typed", "BR-V4", BRIDGE, + "local disposable, `type` present", + rf"local IDisposable '{N}' \(type '{N}'\) is created but never disposed " + rf"\(leak\)", (), ("disposable",)), + Branch("token_local_disposable_untyped", "BR-V4", BRIDGE, "local disposable, no `type`", + rf"local IDisposable '{N}' is created but never disposed \(leak\)", + (), ("disposable",)), + Branch("token_subscribe_injected", "BR-V4", BRIDGE, + "ignored Subscribe() result, injected source", + rf"the result of '{N}' is ignored — its IDisposable subscription is never " + rf"disposed; the source is an injected dependency whose lifetime is unknown, " + rf"so it may outlive and keep '{N}' alive \(possible leak\)", + (), ("subscription token",)), + Branch("token_subscribe_other", "BR-V4", BRIDGE, + "ignored Subscribe() result, any other source", + rf"the result of '{N}' is ignored — the IDisposable subscription is never " + rf"disposed, leaking '{N}' \(leak\)", (), ("subscription token",)), + Branch("pooled_never_returned", "BR-V4", BRIDGE, + "pooled buffer never returned — the `pool` token wording AND the " + "flow-local never-returned wording, one sentence (see `Branch`)", + rf"pooled buffer '{N}' is rented but never returned to the pool \(leak\)", + ("OWN001",), ("pooled buffer",)), + Branch("token_subscription_injected", "BR-V4", BRIDGE, + "plain `+=` subscription, injected source", + rf"event '{N}' is subscribed \(handler '{N}'\) but never unsubscribed; its " + rf"source is an injected dependency whose lifetime is unknown, so it may " + rf"outlive and keep '{N}' alive \(possible leak\)", (), ("subscription token",)), + Branch("token_subscription_injected_lambda", "BR-V4", BRIDGE, + "plain `+=` subscription, injected source, inline lambda", + rf"event '{N}' is subscribed \(handler '{N}'\) but never unsubscribed; its " + rf"source is an injected dependency whose lifetime is unknown, so it may " + rf"outlive and keep '{N}' alive \(possible leak{re.escape(LAMBDA_NOTE)}\)", + (), ("subscription token",)), + Branch("token_subscription_other", "BR-V4", BRIDGE, + "plain `+=` subscription, any other source", + rf"event '{N}' is subscribed \(handler '{N}'\) but never unsubscribed — the " + rf"source keeps '{N}' alive \(leak\)", (), ("subscription token",)), + Branch("token_subscription_other_lambda", "BR-V4", BRIDGE, + "plain `+=` subscription, any other source, inline lambda", + rf"event '{N}' is subscribed \(handler '{N}'\) but never unsubscribed — the " + rf"source keeps '{N}' alive \(leak{re.escape(LAMBDA_NOTE)}\)", + (), ("subscription token",)), + # the advisory side paths. + Branch("advisory_own050", "BR-V4", BRIDGE, "OWN050 unresolved-reference note", + rf"cannot verify '{N}' — its declaring type is an unresolved reference " + rf"\(build the project or pass references\); leakage analysis skipped", + ("OWN050",)), + Branch("advisory_own051", "BR-V4", BRIDGE, "OWN051 unverified-transfer note", + rf"cannot verify whether '{N}' takes ownership of '{N}' \(inferred contract: " + rf"{N}\); optimistically assuming it does — '{N}' is not checked past this call", + ("OWN051",)), + Branch("advisory_own052", "BR-V4", BRIDGE, "OWN052 degraded-inference note", + rf"interprocedural summary inference failed \({N}\); method summaries " + rf"skipped — cross-method ownership transfer was not checked this run", + ("OWN052",)), + # the messages the bridge does NOT synthesize: the DI and effect finders' + # own `message` property (ownlang/di.py, ownlang/effects.py). + Branch("di001_message", "BR-V4", CORE_ANALYSIS, "DI001 captive message (di.py)", + rf"singleton '{N}' captures scoped service '{N}' \(captive dependency: {N}\)" + rf"(?: \[consumed by {N} at {N}\])?", ("DI001",)), + Branch("di002_message", "BR-V4", CORE_ANALYSIS, "DI002 weak-captive message (di.py)", + rf"singleton '{N}' weakly captures scoped service '{N}' \(WeakReference\): .*" + rf"(?: \[consumed by {N} at {N}\])?", ("DI002",)), + Branch("di003_message", "BR-V4", CORE_ANALYSIS, + "DI003 captured-transient message (di.py)", + rf"singleton '{N}' captures transient IDisposable '{N}': .*" + rf"(?: \[consumed by {N} at {N}\])?", ("DI003",)), + Branch("di004_message", "BR-V4", CORE_ANALYSIS, "DI004 root-resolution message (di.py)", + rf"singleton '{N}' resolves transient IDisposable '{N}' by hand from its " + rf"injected root IServiceProvider .*", ("DI004",)), + Branch("di005_message", "BR-V4", CORE_ANALYSIS, "DI005 scope-cache message (di.py)", + rf"singleton '{N}' caches scoped service '{N}', resolved from a scope it " + rf"creates, into a field: .*", ("DI005",)), + Branch("eff001_message", "BR-V4", CORE_ANALYSIS, "EFF001 storm message (effects.py)", + rf"effect re-runs on every render: dependency '{N}'.*", ("EFF001",)), + # the protocol family: bridge-synthesized, but the analysis behind it is + # #259 row 4b and refused by the port — out of cp5's scope by declaration. + Branch("obl_message", "BR-V4", PROTOCOL, "OBL001-005 message (4b, not cp5)", + r".*", ("OBL001", "OBL002", "OBL003", "OBL004", "OBL005"), + note=PROTOCOL_IS_4B), +) + +# The `_consumed_suffix` / `[singleton registered at …]` tails ride inside the +# analysis messages above; they are counted separately because each is its own +# degradation rule (an unknown ctor location drops the tail entirely). +MESSAGE_TAILS: tuple[tuple[str, str, tuple[str, ...], str], ...] = ( + ("tail_consumed_typed", "` [consumed by the '' constructor at f:l]`", + ("DI001", "DI002", "DI003"), r".* \[consumed by the '.*?' constructor at .*?:\d+\]"), + ("tail_consumed_bare", "` [consumed by the constructor at f:l]` (impl type unknown)", + ("DI001", "DI002", "DI003"), r".* \[consumed by the constructor at .*?:\d+\]"), + ("tail_consumed_absent", "no consuming-constructor tail (location unknown)", + ("DI001", "DI002", "DI003"), r"(?!.*\[consumed by ).*"), + ("tail_registered", "` [singleton registered at f:l]` on DI004/DI005", + ("DI004", "DI005"), r".* \[singleton registered at .*?:\d+\]"), + ("tail_registered_absent", "no registration tail (the primary IS the registration)", + ("DI004", "DI005"), r"(?!.*\[singleton registered at ).*"), +) + + +# --- BR-V5: the evidence slices ------------------------------------------ +@dataclass(frozen=True) +class SliceFamily: + """One `related`/`flow` shape: which field, which codes, how many steps, and + the label each step must carry. A slice matching no family (or two) is a + ledger problem, exactly as an unmatched message is.""" + + id: str + what: str + field: str + codes: tuple[str, ...] + labels: tuple[str, ...] + # `True` when `labels` is (first, repeated-middle, last) rather than an + # exact per-step list — the DI path slices, whose middle hops repeat. + variadic: bool = False + #: see `Branch.note`. + note: str = "" + + def matches(self, code: str, steps: list[list[object]]) -> bool: + if self.codes and code not in self.codes: + return False + labels = [s[2] if len(s) == 3 else None for s in steps] + if not all(isinstance(x, str) for x in labels): + return False + want: list[str] + if self.variadic: + first, middle, last = self.labels + if len(labels) < 3: + return False + want = [first, *([middle] * (len(labels) - 2)), last] + else: + want = list(self.labels) + if len(labels) != len(want): + return False + return all(re.fullmatch(w, str(g), re.DOTALL) + for w, g in zip(want, labels, strict=True)) + + +DI_CODES = ("DI001", "DI002", "DI003", "DI004", "DI005") +_CAPTOR = rf"singleton '{N}' \(captor\)" +_VIA = rf"via '{N}'" +_END = r"(?:captures scoped service|weakly captures scoped service|captures transient " +_END += rf"IDisposable|leaks transient IDisposable|caches scoped service) '{N}'" + +SLICE_FAMILIES: tuple[SliceFamily, ...] = ( + SliceFamily("di_path_1", "DI retention path, one resolvable hop (the rest dropped)", + "flow", DI_CODES, (_END,)), + SliceFamily("di_path_2", "DI retention path, captor → captured", "flow", DI_CODES, + (_CAPTOR, _END)), + SliceFamily("di_path_3plus", "DI retention path with `via` hops", "flow", DI_CODES, + (_CAPTOR, _VIA, _END), variadic=True), + SliceFamily("di_consumer_related_typed", "DI consuming constructor, impl type known", + "related", ("DI001", "DI002", "DI003"), + (rf"consuming constructor of '{N}'",)), + SliceFamily("di_consumer_related_bare", "DI consuming constructor, impl type unknown", + "related", ("DI001", "DI002", "DI003"), (r"consuming constructor",)), + SliceFamily("di004_registration_related", "DI004 registration beside the call site", + "related", ("DI004",), (rf"registration of singleton '{N}'",)), + SliceFamily("di005_registration_related", "DI005 registration beside the store site", + "related", ("DI005",), (rf"registration of singleton '{N}'",)), + SliceFamily("capture_escape_flow", "OWN014 subscribe site → source registration site", + "flow", ("OWN014",), + (rf"'{N}' subscribes '{N}' to '{N}' here", + rf"source '{N}' \({N}\) registered here — outlives '{N}'")), + SliceFamily("effect_flow", "EFF001 re-run site → identity-mint site", "flow", + ("EFF001",), + (rf"effect re-runs here on '{N}'", + rf"'{N}' gets a fresh identity here — stabilise with useMemo")), + *(SliceFamily(f"flowlocal_flow_{code.lower()}{'_pool' if pool else ''}", + f"flow-local {code} origin → violation" + f"{' (pooled)' if pool else ''}", "flow", (code,), + (rf"{'rented' if pool else 'acquired'} '{N}' here", re.escape(viol))) + for pool in (False, True) + for code, viol in ( + ("OWN002", "used here after it was released/returned"), + ("OWN003", "released/returned here a second time"), + ("OWN009", "may be used here after release on some path"), + ("OWN025", "viewed here at full length, past what it was rented for")) + if not (code == "OWN025" and not pool)), + SliceFamily("protocol_flow", "OBL opened → barrier (→ late close) — 4b, not cp5", + "flow", ("OBL001", "OBL002", "OBL003", "OBL004", "OBL005"), + (r".*", r".*"), note=PROTOCOL_IS_4B), + SliceFamily("protocol_flow_3", "OBL opened → barrier → late close — 4b, not cp5", + "flow", ("OBL001", "OBL002", "OBL003", "OBL004", "OBL005"), + (r".*", r".*", r".*"), note=PROTOCOL_IS_4B), +) + +# The degradations BR-V5 names in prose. Counted as their own rows because a +# rule that only ever fires as "the slice is present" is a rule with no +# negative control. +@dataclass(frozen=True) +class Degradation: + id: str + what: str + field: str + codes: tuple[str, ...] + kinds: tuple[str, ...] = () + handler_empty: bool | None = None + #: matched against the finding's message when the code alone does not say + #: which branch minted it — an OWN014 builds an escape slice only on the + #: DI-sourced branch, and only the wording tells the two apart here. + message: str | None = None + + +DEGRADATIONS: tuple[Degradation, ...] = ( + Degradation("di_consumer_related_dropped", + "DI001/2/3 with no consuming-constructor related (line < 1)", + "related", ("DI001", "DI002", "DI003")), + Degradation("di004_related_dropped", + "DI004 with no registration related (the primary IS the registration)", + "related", ("DI004",)), + Degradation("di005_related_dropped", + "DI005 with no registration related (the primary IS the registration)", + "related", ("DI005",)), + Degradation("capture_escape_flow_dropped", + "DI-sourced OWN014 with no escape slice (source registration unknown " + "→ < 2 steps)", "flow", ("OWN014",), message=rf".* to '{N}' — a DI .*"), + Degradation("capture_flow_absent", + "OWN014 from the capture route: no escape slice by design " + "(only the DI-sourced branch builds one)", "flow", ("OWN014",), + message=r".* to (?:a static \(process-lived\) event source" + r"|a longer-lived source) .*"), + Degradation("effect_flow_dropped", + "EFF001 with no slice (a re-run or mint line < 1)", "flow", ("EFF001",)), + Degradation("flowlocal_flow_absent", + "OWN001 on a local/pooled record: a single-point finding, no slice " + "by design", "flow", ("OWN001",), ("disposable", "pooled buffer")), +) + + +# --- BR-V9: the rendered surfaces ---------------------------------------- +@dataclass(frozen=True) +class RenderBranch: + """One rendered-surface rule. `probe` names what a cp5.3 golden must + contain for the row to count as covered; until that family exists every + row reads uncovered, which is the honest state of BR-V9 at cp5.0.""" + + id: str + surface: str + what: str + + +RENDER_BRANCHES: tuple[RenderBranch, ...] = ( + RenderBranch("human_line", "render", "`file:line: sev: [code] msg [resource: kind]`"), + RenderBranch("human_severity", "render", "host severity in the human line"), + RenderBranch("github_line", "github", "`::sev file=…,line=…,title=CODE::msg`"), + RenderBranch("github_severity", "github", "host severity as the annotation level"), + RenderBranch("github_esc_percent", "github", "`%` → `%25` in the message data"), + RenderBranch("github_esc_cr", "github", "CR → `%0D` in the message data"), + RenderBranch("github_esc_lf", "github", "LF → `%0A` in the message data"), + RenderBranch("github_esc_prop_colon", "github", "`:` → `%3A` in a property value"), + RenderBranch("github_esc_prop_comma", "github", "`,` → `%2C` in a property value"), + RenderBranch("msbuild_line", "msbuild", "`file(line): sev CODE: msg [resource: kind]`"), + RenderBranch("msbuild_severity", "msbuild", "host severity in the msbuild line"), + RenderBranch("fallback_human", "render", "an unknown format falls back to the human line"), + RenderBranch("sarif_envelope", "sarif", "`$schema` + `version` + one `run`"), + RenderBranch("sarif_driver", "sarif", "`tool.driver.name` = Owen + `informationUri`"), + RenderBranch("sarif_rules", "sarif", "rule catalogue: sorted, deduplicated + `TITLES`"), + RenderBranch("sarif_result_order", "sarif", + "results keep the bridge's own order (BR-V8), never the catalogue's"), + RenderBranch("sarif_schema_version", "sarif", "the `ownirSchemaVersion` driver property"), + RenderBranch("sarif_level_note", "sarif", "an advisory renders as `note`"), + RenderBranch("sarif_level_warning", "sarif", "an intrinsic warning renders as `warning`"), + RenderBranch("sarif_level_error", "sarif", "a provable leak renders as `error`"), + RenderBranch("sarif_level_host_warning", "sarif", + "`severity=warning` downgrades an error, never an advisory"), + RenderBranch("sarif_region", "sarif", "`region.startLine` for a line ≥ 1"), + RenderBranch("sarif_region_omitted", "sarif", "`region` omitted entirely for line < 1"), + RenderBranch("sarif_start_column", "sarif", "`region.startColumn` only beside a line"), + RenderBranch("sarif_uri_backslash", "sarif", "backslashes normalised in the artifact URI"), + RenderBranch("sarif_properties", "sarif", + "`resourceKind` always; component/event/handler only when non-empty"), + RenderBranch("sarif_related", "sarif", "`relatedLocations` from `related`"), + RenderBranch("sarif_code_flows", "sarif", "`codeFlows` from the ordered `flow`"), + RenderBranch("sarif_suppressions", "sarif", + "`suppressions` (`inSource` + justification) for a suppressed finding"), + RenderBranch("sarif_empty", "sarif", "an empty finding list is a valid, empty run"), + RenderBranch("refusal_error", "surface", "a bridge refusal projects as `{\"error\": …}`"), +) + + +#: The disposition every BR-V9 row carries while its family does not exist. +RENDER_FAMILY_PENDING = ( + "no fixture family yet — checkpoint 5.3 builds `tests/fixtures/verdict_renders/`; " + "once it exists an uncovered row here is a missing control") +#: A rendered case pins a ROW, not a golden's own count, so its `all goldens` +#: and `replayed` columns are the same number: the family has no excluded set. + + +class InventoryError(Exception): + """The tree is not in a state an inventory may be taken over.""" + + def __init__(self, problems: list[str]) -> None: + super().__init__("; ".join(problems)) + self.problems = tuple(problems) + + +@dataclass(frozen=True) +class Coverage: + """One ledger row's measured coverage: findings (or slices) over all + goldens, over the replayed set, and the cases that reach it.""" + + id: str + what: str + detail: str + total: int + replayed: int + cases: tuple[str, ...] + note: str = "" + + +@dataclass(frozen=True) +class SurfaceInventory: + messages: tuple[Coverage, ...] + tails: tuple[Coverage, ...] + slices: tuple[Coverage, ...] + degradations: tuple[Coverage, ...] + renders: tuple[Coverage, ...] + render_family_exists: bool + + +def _load(p: Plan, fixdir: str) -> tuple[dict[str, list[dict[str, object]]], list[str]]: + """case -> its golden's findings (a refused case contributes none).""" + out: dict[str, list[dict[str, object]]] = {} + problems: list[str] = [] + for name in sorted(p.cases): + path = os.path.join(fixdir, f"{name}{GOLDEN_SUFFIX}") + try: + with open(path, encoding="utf-8") as f: + doc = json.load(f) + except (OSError, ValueError) as e: + problems.append(f"{name}: unreadable golden: {e}") + continue + if not isinstance(doc, dict): + problems.append(f"{name}: golden is not a JSON object") + continue + findings = doc.get("findings") + if findings is None: + out[name] = [] + continue + if not isinstance(findings, list) or not all(isinstance(x, dict) for x in findings): + problems.append(f"{name}: golden findings must be an array of objects") + continue + out[name] = list(findings) + return out, problems + + +def _tally(hits: dict[str, list[str]], excluded: set[str], ledger_id: str, + what: str, detail: str, note: str = "") -> Coverage: + cases = hits.get(ledger_id, []) + return Coverage(ledger_id, what, detail, len(cases), + sum(1 for c in cases if c not in excluded), + tuple(sorted(set(cases))), note) + + +def compute_surface_inventory(p: Plan | None = None, + fixdir: str = FIXDIR) -> SurfaceInventory: + """Match every committed golden against the three ledgers. Raises + `InventoryError` on a plan problem, an unreadable golden, or a finding / + slice the ledger cannot place — an inventory over a tree the ledger does + not describe is exactly the stale number this module exists to prevent.""" + if p is None: + p = plan(fixdir=fixdir) + problems = list(p.problems) + goldens, load_problems = _load(p, fixdir) + problems.extend(load_problems) + if problems: + raise InventoryError(problems) + excluded = set(p.excluded) + + msg_hits: dict[str, list[str]] = {} + tail_hits: dict[str, list[str]] = {} + slice_hits: dict[str, list[str]] = {} + degradation_hits: dict[str, list[str]] = {} + for case, findings in goldens.items(): + for f in findings: + matched = [b.id for b in MESSAGE_BRANCHES if b.matches(f)] + if len(matched) != 1: + problems.append( + f"{case}: message matches {len(matched)} BR-V4 branches " + f"({', '.join(matched) or 'none'}): {f.get('message')!r}") + for bid in matched: + msg_hits.setdefault(bid, []).append(case) + code = str(f.get("code", "")) + message = str(f.get("message", "")) + for tid, _what, codes, pattern in MESSAGE_TAILS: + if code in codes and re.fullmatch(pattern, message, re.DOTALL): + tail_hits.setdefault(tid, []).append(case) + for field in ("related", "flow"): + steps = f.get(field) + if not isinstance(steps, list): + problems.append(f"{case}: {field} is not an array") + continue + if not steps: + for d in DEGRADATIONS: + if d.field != field or (d.codes and code not in d.codes): + continue + if d.kinds and f.get("kind") not in d.kinds: + continue + if d.handler_empty is not None and \ + (f.get("handler") == "") != d.handler_empty: + continue + if d.message is not None and not re.fullmatch( + d.message, message, re.DOTALL): + continue + degradation_hits.setdefault(d.id, []).append(case) + continue + typed = [s for s in steps if isinstance(s, list)] + hit = [fam.id for fam in SLICE_FAMILIES + if fam.field == field and fam.matches(code, typed)] + if len(hit) != 1: + problems.append( + f"{case}: a {code} {field} slice matches {len(hit)} BR-V5 " + f"families ({', '.join(hit) or 'none'}): {typed!r}") + for fid in hit: + slice_hits.setdefault(fid, []).append(case) + if problems: + raise InventoryError(problems) + + rp = render_plan() + problems.extend(f"rendered-surface ledger: {p_}" for p_ in rp.problems) + render_exists = bool(rp.cases) + render_hits: dict[str, list[str]] = { + row: list(cases) for row, cases in pinned_rows(rp).items() + } + known_rows = {r.id for r in RENDER_BRANCHES} + for unknown in sorted(set(render_hits) - known_rows): + problems.append(f"rendered-surface case(s) {render_hits[unknown]} pin " + f"'{unknown}', which is not a BR-V9 ledger row") + if problems: + raise InventoryError(problems) + + return SurfaceInventory( + messages=tuple(_tally(msg_hits, excluded, b.id, b.what, b.source, b.note) + for b in MESSAGE_BRANCHES), + tails=tuple(_tally(tail_hits, excluded, tid, what, "wording tail") + for tid, what, _c, _p in MESSAGE_TAILS), + slices=tuple(_tally(slice_hits, excluded, fam.id, fam.what, fam.field, fam.note) + for fam in SLICE_FAMILIES), + degradations=tuple(_tally(degradation_hits, excluded, d.id, d.what, d.field) + for d in DEGRADATIONS), + # Until cp5.3 builds the family there is nothing to match against, so + # every row carries that as its disposition. The moment the family + # exists the note goes away and an uncovered row reads as the missing + # control it would then be. + renders=tuple(_tally(render_hits, excluded, r.id, r.what, r.surface, + "" if render_exists else RENDER_FAMILY_PENDING) + for r in RENDER_BRANCHES), + render_family_exists=render_exists, + )