diff --git a/docs/evidence/p022-cp4-mutations.json b/docs/evidence/p022-cp4-mutations.json index 6477aa34..04f01da1 100644 --- a/docs/evidence/p022-cp4-mutations.json +++ b/docs/evidence/p022-cp4-mutations.json @@ -159,10 +159,10 @@ }, { "id": "M14", - "description": "protocol boundary: a protocol-bearing document is no longer refused", + "description": "protocol family silently missing: the OBL findings are computed and then dropped. Re-anchored at #259 cp4b — the rule cp4 stated as a REFUSAL (a protocol-bearing document must never get a verdict list with a family missing) is now stated as a WIRING, and this is the same rule attacked on today's tree", "target": "rust/crates/own-bridge/src/verdict.rs", - "pattern": "Some\\(Value::Array\\(items\\)\\) if !items\\.is_empty\\(\\) => Err\\(", - "replacement": "Some(Value::Array(items)) if items.is_empty() => Err(", + "pattern": " findings\\.extend\\(protocol_findings\\(root\\)\\);", + "replacement": " let _ = protocol_findings(root);", "expected_catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ] diff --git a/docs/evidence/p022-cp4-mutations.result.json b/docs/evidence/p022-cp4-mutations.result.json index 53846071..b5bf3fac 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": "15b10f36c0d2b46b5e3d4815babc57bfbebefecde0f6c5579e7e9d666bd1edb9", - "source_commit": "5553dae32ddf5d22592e11e84c8ed10e8c2fbc3e", + "definition_sha256": "c4ca44f6bbc92cdd146931107e1e4959e1bdeb79eed9a717a874b31eba2ae42e", + "source_commit": "5085a5152916b1dae260781cf850733095720fe6", "dirty": false, - "recorded_at": "2026-09-06T15:08:56Z", + "recorded_at": "2026-09-06T20:51:00Z", "packages": [ "own-analysis", "own-bridge", @@ -22,7 +22,7 @@ "id": "M00", "outcome": "survived", "catchers": [], - "elapsed_seconds": 1.6 + "elapsed_seconds": 3.0 }, "mutations": [ { @@ -32,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.6 + "elapsed_seconds": 3.3 }, { "id": "M02", @@ -40,7 +40,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.9 + "elapsed_seconds": 4.4 }, { "id": "M03", @@ -48,7 +48,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.9 + "elapsed_seconds": 4.1 }, { "id": "M04", @@ -56,7 +56,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.8 + "elapsed_seconds": 3.9 }, { "id": "M05", @@ -64,7 +64,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.8 + "elapsed_seconds": 3.9 }, { "id": "M06", @@ -81,16 +81,17 @@ "own-analysis/tests/fact_parity.rs::di_fact_parity", "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.9 + "elapsed_seconds": 4.2 }, { "id": "M08", "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": 3.8 + "elapsed_seconds": 4.1 }, { "id": "M09", @@ -98,7 +99,7 @@ "catchers": [ "own-bridge/src/lib.rs::verdict::tests::malformed_effect_entries_are_skipped_not_coerced" ], - "elapsed_seconds": 3.9 + "elapsed_seconds": 4.0 }, { "id": "M10", @@ -106,16 +107,17 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.9 + "elapsed_seconds": 4.0 }, { "id": "M11", "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 + "elapsed_seconds": 4.7 }, { "id": "M12", @@ -124,10 +126,11 @@ "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/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 + "elapsed_seconds": 4.7 }, { "id": "M13", @@ -136,16 +139,17 @@ "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.9 + "elapsed_seconds": 4.0 }, { "id": "M14", "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": 3.9 + "elapsed_seconds": 4.4 }, { "id": "M15", @@ -153,16 +157,17 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.8 + "elapsed_seconds": 3.9 }, { "id": "M16", "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": 3.8 + "elapsed_seconds": 4.1 }, { "id": "M17", @@ -170,7 +175,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.7 + "elapsed_seconds": 4.1 }, { "id": "M18", @@ -179,7 +184,7 @@ "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.8 + "elapsed_seconds": 4.2 }, { "id": "M19", @@ -187,7 +192,7 @@ "catchers": [ "own-bridge/src/lib.rs::verdict::tests::only_error_severity_core_verdicts_are_mapped" ], - "elapsed_seconds": 3.6 + "elapsed_seconds": 3.9 }, { "id": "M20", @@ -195,7 +200,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.6 + "elapsed_seconds": 4.0 }, { "id": "M21", @@ -203,15 +208,16 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.8 + "elapsed_seconds": 4.2 }, { "id": "M22", "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": 3.6 + "elapsed_seconds": 4.1 }, { "id": "M23", @@ -221,10 +227,11 @@ "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/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": 3.7 + "elapsed_seconds": 4.1 }, { "id": "M24", @@ -232,7 +239,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.8 + "elapsed_seconds": 4.0 }, { "id": "M25", @@ -240,7 +247,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.9 + "elapsed_seconds": 4.1 }, { "id": "M26", @@ -248,7 +255,7 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.8 + "elapsed_seconds": 4.1 }, { "id": "M27", @@ -256,7 +263,7 @@ "catchers": [ "own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member" ], - "elapsed_seconds": 3.5 + "elapsed_seconds": 4.0 }, { "id": "M28", @@ -264,7 +271,7 @@ "catchers": [ "own-bridge/src/lib.rs::verdict::tests::dedup_keeps_findings_that_differ_only_in_an_unobservable_key_member" ], - "elapsed_seconds": 3.6 + "elapsed_seconds": 4.1 }, { "id": "M29", @@ -272,17 +279,18 @@ "catchers": [ "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" ], - "elapsed_seconds": 3.7 + "elapsed_seconds": 4.1 }, { "id": "M30", "outcome": "caught", "catchers": [ "own-bridge/src/lib.rs::verdict::tests::di_coercions_match_the_reference", + "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": 3.9 + "elapsed_seconds": 4.3 } ] } diff --git a/docs/evidence/p022-cp4b-1.json b/docs/evidence/p022-cp4b-1.json new file mode 100644 index 00000000..9ec070a7 --- /dev/null +++ b/docs/evidence/p022-cp4b-1.json @@ -0,0 +1,387 @@ +{ + "schema": 1, + "comment": "Mutation campaign definition for #259 checkpoint 4b.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 rule it attacks, and the tests expected to fail. Replay: python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp4b-1.json --run (on a clean tree). The recorded run lives beside this file as p022-cp4b-1.result.json; counts are derived from it by scripts/render_checkpoint_status.py, never typed.", + "campaign": "p022-cp4b-1", + "description": "#259 cp4b.1, the obligation-protocol ANALYSIS: the {OPEN, CLOSED} lattice and its min-line provenance, the definite/maybe split, the opens-before-closes-before-barriers leaf order with allow beating barrier, the never-invent asymmetry of an opaque write, the exit rules and the anchor-at-open precedent, the branch join, the loop's silent fixpoint and single emitting pass, the reachability-blind close-line evidence with its strictly-after and barriers-only rules, and the four-part sort key — plus the half of the shared grammar the analysis reads (scope matching, tracked flags, args narrowing, assign-value narrowing and the record defaults). M16 attacks `_emit`'s own guard: the guard around the loop's emitting pass is provably redundant (emission is re-checked inside `_emit`), so no mutation of it can be non-equivalent — measured, not assumed. Two mutations attack the CONTROL rather than the port: they corrupt the frozen oracle in tests/fixtures/obligation_fact_parity.json and require the replay to fail, because a replay that survives a drifting oracle is comparing against nothing.", + "workspace": "rust", + "control": { + "id": "M00", + "description": "no mutation — the unmutated tree must pass (harness honesty control)" + }, + "mutations": [ + { + "id": "M01", + "rule": "OwnIR §8 lattice", + "description": "the merge drops the other path's states: a union becomes 'keep the left'", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "states: self\\.states\\.union\\(other\\.states\\),", + "replacement": "states: self.states,", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M02", + "rule": "OwnIR §8 lattice", + "description": "open provenance joins by the LATEST line instead of the earliest", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "\\(Some\\(a\\), Some\\(b\\)\\) => Some\\(a\\.min\\(b\\)\\),", + "replacement": "(Some(a), Some(b)) => Some(a.max(b)),", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M03", + "rule": "OwnIR §8 lattice", + "description": "the state union intersects OPEN instead of joining it", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "open: self\\.open \\|\\| other\\.open,", + "replacement": "open: self.open && other.open,", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M04", + "rule": "OwnIR §8 definite/maybe", + "description": "definite reads 'OPEN is possible' instead of 'OPEN on every path'", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "definite: st\\.states == States::OPEN,", + "replacement": "definite: st.states.open,", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M05", + "rule": "obligations._leaf", + "description": "a re-open moves the provenance forward instead of keeping the earliest site", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "open_line: Some\\(st\\.open_line\\.map_or\\(line, \\|prev\\| prev\\.min\\(line\\)\\)\\),", + "replacement": "open_line: Some(st.open_line.map_or(line, |prev| prev.max(line))),", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M06", + "rule": "obligations._leaf (allow beats barrier)", + "description": "the allow list no longer exempts an event from the barrier check", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "if !p\\.allow\\.iter\\(\\)\\.any\\(\\|a\\| a\\.matches\\(ev\\)\\) && p\\.barriers", + "replacement": "if p.barriers", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M07", + "rule": "OwnIR §8 precision policy", + "description": "an opaque write DISCHARGES definitely instead of adding CLOSED to the set", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "states: st\\.states\\.union\\(States::CLOSED\\),", + "replacement": "states: States::CLOSED,", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M08", + "rule": "OwnIR §8 precision policy", + "description": "an opaque write to an UNTRACKED member discharges the obligation", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "if p\\.tracks_target\\(target\\) \\{", + "replacement": "if true {", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M09", + "rule": "obligations._exit", + "description": "exit_barriers is ignored: return/throw always cross", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "if self\\.proto\\.exit_barriers && st\\.states\\.open \\{\\n(\\s+)self\\.emit\\(ViolationKind::Exit, line, st, desc\\.to_owned\\(\\)\\);", + "replacement": "if st.states.open {\n\\1self.emit(ViolationKind::Exit, line, st, desc.to_owned());", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M10", + "rule": "OwnIR §8 anchor-at-open (the OWN001 precedent)", + "description": "an end-of-method leak anchors at 0 instead of at the open site", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "let anchor = st\\.open_line\\.unwrap_or\\(0\\);", + "replacement": "let anchor = 0;", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open" + ] + }, + { + "id": "M11", + "rule": "obligations.walk_seq", + "description": "a sequence keeps walking past an event that left the method", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": " if !alive \\{\\n return \\(st, false\\);\\n \\}\\n \\}\\n \\(st, true\\)", + "replacement": " let _ = alive;\n }\n (st, true)", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M12", + "rule": "obligations.walk (if)", + "description": "ONE dead arm ends the method instead of both", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": " if !a1 && !a2 \\{", + "replacement": " if !a1 || !a2 {", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M13", + "rule": "obligations.walk (if)", + "description": "the merge keeps only the then-arm instead of joining both", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "\\(left\\.join\\(right\\), true\\)", + "replacement": "{\n let _ = right;\n (left, true)\n }", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M14", + "rule": "obligations.walk (while) — zero iterations", + "description": "the fixpoint forgets the header, so a loop can no longer run zero times", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "let next = header\\.join\\(if body_alive \\{ out \\} else \\{ State::BOTTOM \\}\\);", + "replacement": "let next = if body_alive { out } else { State::BOTTOM };", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M15", + "rule": "obligations.walk (while) — two-phase emission", + "description": "the fixpoint iterations emit, so a barrier in a loop reports more than once", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": " self\\.silent = true;", + "replacement": " self.silent = was_silent;", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-analysis/src/lib.rs::obligation::tests::a_barrier_in_a_loop_reports_exactly_once" + ] + }, + { + "id": "M16", + "rule": "obligations._emit (the silent phase)", + "description": "the silent phase does not suppress emission, so a loop's fixpoint iterations report. Attacks `_emit`'s guard rather than the redundant one around the emitting pass: with `_emit` guarded, removing `if !self.silent` around `walk_seq` is an EQUIVALENT mutant (measured — it survived), because a nested pass under an enclosing silent phase emits nothing either way", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": " if self\\.silent \\{\\n return;\\n \\}", + "replacement": " if false {\n return;\n }", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-analysis/src/lib.rs::obligation::tests::a_barrier_in_a_loop_reports_exactly_once" + ] + }, + { + "id": "M17", + "rule": "obligations._close_lines", + "description": "close lines inside a loop body are not collected as evidence", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": " Event::While \\{ body, \\.\\. \\} => close_lines\\(proto, body, out\\),", + "replacement": " Event::While { .. } => {}", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M18", + "rule": "obligations.check_protocols (late close)", + "description": "the late close is 'at or after' the barrier instead of strictly after", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "closes\\.iter\\(\\)\\.find\\(\\|c\\| \\*\\*c > v\\.line\\)", + "replacement": "closes.iter().find(|c| **c >= v.line)", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M19", + "rule": "obligations.check_protocols (late close)", + "description": "the late-close hop is attached to exit leaks instead of barrier crossings", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": "if v\\.kind == ViolationKind::Barrier \\{", + "replacement": "if v.kind != ViolationKind::Barrier {", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open" + ] + }, + { + "id": "M20", + "rule": "obligations.check_protocols (sort key)", + "description": "violations sort by line before file", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": " a\\.file\\n \\.cmp\\(&b\\.file\\)\\n \\.then_with\\(\\|\\| a\\.line\\.cmp\\(&b\\.line\\)\\)", + "replacement": " a.line\n .cmp(&b.line)\n .then_with(|| a.file.cmp(&b.file))", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M21", + "rule": "obligations.check_protocols (sort key)", + "description": "the protocol tiebreak is dropped from the sort key", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": " \\.then_with\\(\\|\\| a\\.protocol\\.cmp\\(&b\\.protocol\\)\\)\\n", + "replacement": "", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M22", + "rule": "obligations.check_protocols (sort key)", + "description": "the barrier-description tiebreak is dropped from the sort key", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": " \\.then_with\\(\\|\\| a\\.barrier_desc\\.cmp\\(&b\\.barrier_desc\\)\\)\\n", + "replacement": "", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M23", + "rule": "obligations.unmatched_scopes", + "description": "an UNSCOPED protocol is reported as a dead rule", + "target": "rust/crates/own-analysis/src/obligation.rs", + "pattern": " \\.filter\\(\\|p\\| !p\\.methods\\.is_empty\\(\\) && !methods\\.iter\\(\\)\\.any\\(\\|m\\| p\\.applies_to\\(&m\\.name\\)\\)\\)", + "replacement": " .filter(|p| !methods.iter().any(|m| p.applies_to(&m.name)))", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-analysis/src/lib.rs::obligation::tests::a_dead_scope_is_reported_and_an_unscoped_protocol_is_not" + ] + }, + { + "id": "M24", + "rule": "OwnIR §8 scope (suffix match)", + "description": "a scope entry matches anywhere in the method name instead of as a suffix", + "target": "rust/crates/own-ir/src/protocol.rs", + "pattern": "\\.any\\(\\|m\\| fn_name == m \\|\\| fn_name\\.ends_with\\(&format!\\(\\\"\\.\\{m\\}\\\"\\)\\)\\)", + "replacement": ".any(|m| fn_name == m || fn_name.contains(m))", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value" + ] + }, + { + "id": "M25", + "rule": "OwnIR §8 scope (empty = every method)", + "description": "an empty scope is inverted: a scoped protocol applies everywhere", + "target": "rust/crates/own-ir/src/protocol.rs", + "pattern": " self\\.methods\\.is_empty\\(\\)\\n", + "replacement": " !self.methods.is_empty()\n", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value" + ] + }, + { + "id": "M26", + "rule": "OwnIR §8 tracked flags", + "description": "tracks_target ignores the member name, so every assign target is tracked", + "target": "rust/crates/own-ir/src/protocol.rs", + "pattern": "\\.any\\(\\|m\\| m\\.kind == MatcherKind::Assign && m\\.target == target\\)", + "replacement": ".any(|m| m.kind == MatcherKind::Assign)", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value" + ] + }, + { + "id": "M27", + "rule": "OwnIR §8 precision policy (unknown argument)", + "description": "a call with an UNKNOWN argument matches an args-narrowed matcher", + "target": "rust/crates/own-ir/src/protocol.rs", + "pattern": "\\|\\| arg\\.as_ref\\(\\)\\.is_some_and\\(\\|a\\| self\\.args\\.contains\\(a\\)\\)\\)", + "replacement": "|| arg.as_ref().map_or(true, |a| self.args.contains(a)))", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_narrowed_call_matcher_never_matches_an_unknown_argument" + ] + }, + { + "id": "M28", + "rule": "OwnIR §8 matcher (assign value narrowing)", + "description": "an assign matcher ignores the written value, so a close also opens", + "target": "rust/crates/own-ir/src/protocol.rs", + "pattern": "\\*target == self\\.target && \\(self\\.value\\.is_none\\(\\) \\|\\| \\*value == self\\.value\\)", + "replacement": "*target == self.target", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M29", + "rule": "OwnIR §8 grammar (exit_barriers default)", + "description": "an absent exit_barriers defaults to false instead of true", + "target": "rust/crates/own-ir/src/protocol.rs", + "pattern": "let exit_barriers = match obj\\.get\\(\\\"exit_barriers\\\"\\) \\{\\n None => true,", + "replacement": "let exit_barriers = match obj.get(\"exit_barriers\") {\n None => false,", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value" + ] + }, + { + "id": "M30", + "rule": "OwnIR §8 grammar (scope construction)", + "description": "a validated scope entry is checked and then dropped, so every scope is empty", + "target": "rust/crates/own-ir/src/protocol.rs", + "pattern": " methods\\.push\\(m\\.to_owned\\(\\)\\);", + "replacement": " let _ = m;", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value" + ] + }, + { + "id": "M31", + "rule": "OwnIR §8 grammar (file default)", + "description": "an absent protocol-function 'file' defaults to the empty string, not '?'", + "target": "rust/crates/own-ir/src/protocol.rs", + "pattern": " None => \\\"\\?\\\"\\.to_owned\\(\\),", + "replacement": " None => String::new(),", + "expected_catchers": [ + "own-ir/src/lib.rs::protocol::tests::a_method_record_becomes_its_event_tree" + ] + }, + { + "id": "M32", + "rule": "P-022 discipline 2 (the control is the thing under test)", + "description": "corrupt the frozen oracle: the anchor of the leak whose open carries no line moves off 0. The replay must fail, or it is not reading the reference's answer", + "target": "tests/fixtures/obligation_fact_parity.json", + "pattern": "\"open_line\": 0,", + "replacement": "\"open_line\": 1,", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + }, + { + "id": "M33", + "rule": "P-022 discipline 2 (the control is the thing under test)", + "description": "corrupt the frozen oracle's dead-rule list. The replay must fail, or the unmatched_scopes half is carried without being compared", + "target": "tests/fixtures/obligation_fact_parity.json", + "pattern": "(\"name\": \"a_dead_rule_and_a_live_one_together\"[\\s\\S]*?)\"dead\": \\[\\n \"DocLoad\"\\n \\]", + "replacement": "\\1\"dead\": [\n \"BatchUpdate\"\n ]", + "expected_catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ] + } + ] +} diff --git a/docs/evidence/p022-cp4b-1.result.json b/docs/evidence/p022-cp4b-1.result.json new file mode 100644 index 00000000..9260d006 --- /dev/null +++ b/docs/evidence/p022-cp4b-1.result.json @@ -0,0 +1,322 @@ +{ + "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-cp4b-1", + "definition": "docs/evidence/p022-cp4b-1.json", + "definition_sha256": "ca44ead1682214048b738ec0c42ccb0cf844c302eb351b050a4c503ae55270f9", + "source_commit": "c7836138bd2fe3b9cfea458a040ac5dda05e890e", + "dirty": false, + "recorded_at": "2026-09-06T20:30:43Z", + "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": 3.5 + }, + "mutations": [ + { + "id": "M01", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 3.8 + }, + { + "id": "M02", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M03", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::an_opaque_write_may_discharge_but_never_opens", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M04", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::an_opaque_write_may_discharge_but_never_opens", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.3 + }, + { + "id": "M05", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.7 + }, + { + "id": "M06", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.8 + }, + { + "id": "M07", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::an_opaque_write_may_discharge_but_never_opens", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M08", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.5 + }, + { + "id": "M09", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.5 + }, + { + "id": "M10", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M11", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.5 + }, + { + "id": "M12", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M13", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M14", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M15", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::a_barrier_in_a_loop_reports_exactly_once", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M16", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::a_barrier_in_a_loop_reports_exactly_once", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M17", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M18", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M19", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open", + "own-analysis/src/lib.rs::obligation::tests::open_barrier_close_is_one_definite_crossing", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.8 + }, + { + "id": "M20", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.7 + }, + { + "id": "M21", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.5 + }, + { + "id": "M22", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M23", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::a_dead_scope_is_reported_and_an_unscoped_protocol_is_not", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M24", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value" + ], + "elapsed_seconds": 5.8 + }, + { + "id": "M25", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::a_barrier_in_a_loop_reports_exactly_once", + "own-analysis/src/lib.rs::obligation::tests::a_dead_scope_is_reported_and_an_unscoped_protocol_is_not", + "own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open", + "own-analysis/src/lib.rs::obligation::tests::an_opaque_write_may_discharge_but_never_opens", + "own-analysis/src/lib.rs::obligation::tests::open_barrier_close_is_one_definite_crossing", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value" + ], + "elapsed_seconds": 6.2 + }, + { + "id": "M26", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value" + ], + "elapsed_seconds": 4.7 + }, + { + "id": "M27", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_narrowed_call_matcher_never_matches_an_unknown_argument" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M28", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::a_barrier_in_a_loop_reports_exactly_once", + "own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open", + "own-analysis/src/lib.rs::obligation::tests::an_opaque_write_may_discharge_but_never_opens", + "own-analysis/src/lib.rs::obligation::tests::close_before_the_barrier_is_clean", + "own-analysis/src/lib.rs::obligation::tests::open_barrier_close_is_one_definite_crossing", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.3 + }, + { + "id": "M29", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::a_dead_scope_is_reported_and_an_unscoped_protocol_is_not", + "own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value", + "own-ir/tests/validation_replay.rs::the_two_loaders_accept_the_same_language" + ], + "elapsed_seconds": 4.4 + }, + { + "id": "M30", + "outcome": "caught", + "catchers": [ + "own-analysis/src/lib.rs::obligation::tests::a_dead_scope_is_reported_and_an_unscoped_protocol_is_not", + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity", + "own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value" + ], + "elapsed_seconds": 4.7 + }, + { + "id": "M31", + "outcome": "caught", + "catchers": [ + "own-ir/src/lib.rs::protocol::tests::a_method_record_becomes_its_event_tree" + ], + "elapsed_seconds": 4.6 + }, + { + "id": "M32", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.7 + }, + { + "id": "M33", + "outcome": "caught", + "catchers": [ + "own-analysis/tests/obligation_parity.rs::obligation_fact_parity" + ], + "elapsed_seconds": 4.1 + } + ] +} diff --git a/docs/evidence/p022-cp4b-2.json b/docs/evidence/p022-cp4b-2.json new file mode 100644 index 00000000..8a710ac1 --- /dev/null +++ b/docs/evidence/p022-cp4b-2.json @@ -0,0 +1,329 @@ +{ + "schema": 1, + "comment": "Mutation campaign definition for #259 checkpoint 4b.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 Bridge.md rule it attacks, and the tests expected to fail. Replay: python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp4b-2.json --run (on a clean tree). The recorded run lives beside this file as p022-cp4b-2.result.json; counts are derived from it by scripts/render_checkpoint_status.py, never typed.", + "campaign": "p022-cp4b-2", + "description": "#259 cp4b.2, the BRIDGE half of the obligation family: the (kind, definite) -> OBL001-004 table, all four line-free wordings and both exit_desc branches, the component/handler derivations, every step of the opened -> barrier (or exit) -> late-close slice with its line guard, the anchorless advisory OBL005 with its sorted CPython-repr scope list, and the three tolerant-door rules (skip a malformed entry whole, first-wins on a duplicate name, a non-list block silences the family). Three mutations attack the CONTROLS rather than the port: two corrupt the frozen Layer 3 and rendered oracles and require their replays to fail, and one re-declares the exclusion this checkpoint promoted — a promotion that were only announced in prose would survive it.", + "workspace": "rust", + "control": { + "id": "M00", + "description": "no mutation — the unmutated tree must pass (harness honesty control)" + }, + "mutations": [ + { + "id": "M01", + "rule": "BR-P3", + "description": "the barrier row of the code table loses its definite/maybe split", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\(ViolationKind::Barrier, true\\) => \"OBL001\",", + "replacement": "(ViolationKind::Barrier, true) => \"OBL002\",", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M02", + "rule": "BR-P3", + "description": "the exit row of the code table loses its definite/maybe split", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\(ViolationKind::Exit, true\\) => \"OBL003\",", + "replacement": "(ViolationKind::Exit, true) => \"OBL004\",", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M03", + "rule": "BR-P3", + "description": "a barrier crossing is coded as an exit leak", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\(ViolationKind::Barrier, false\\) => \"OBL002\",", + "replacement": "(ViolationKind::Barrier, false) => \"OBL004\",", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M04", + "rule": "BR-V4", + "description": "the barrier wording states 'may still be open' for a definite crossing", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " \"is still open\"\\n", + "replacement": " \"may still be open (open on some path)\"\n", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M05", + "rule": "BR-V4", + "description": "the exit wording states 'is not closed' for a maybe leak", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " \"may not be closed \\(open on some path\\)\"", + "replacement": " \"is not closed\"", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M06", + "rule": "BR-V4", + "description": "every exit words itself as the fall-off, never naming the exit site", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " let exit_desc = if v\\.barrier_desc == \"end of method\" \\{", + "replacement": " let exit_desc = if true {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M07", + "rule": "BR-V4", + "description": "the fall-off branch is never taken: a leak off the end words itself as 'exits via end of method'", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " let exit_desc = if v\\.barrier_desc == \"end of method\" \\{\\n(\\s+)\"the method falls off the end\"\\.to_owned\\(\\)", + "replacement": " let exit_desc = if false {\n\\1\"the method falls off the end\".to_owned()", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M08", + "rule": "BR-V4", + "description": "the barrier wording names the barrier where it should name the close", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "'\\{close\\}' must happen first\\\",\\n(\\s+)v\\.protocol, v\\.barrier_desc, v\\.method", + "replacement": "'{close}' must happen first\",\n\\1v.protocol, v.method, v.barrier_desc", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M09", + "rule": "BR-P3", + "description": "component takes the LAST dotted segment instead of the second-to-last", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "String::from\\(method\\.rsplit\\('\\.'\\)\\.nth\\(1\\)\\.unwrap_or\\(method\\)\\)", + "replacement": "String::from(method.rsplit('.').next().unwrap_or(method))", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M10", + "rule": "BR-P3", + "description": "handler takes the FIRST dotted segment instead of the last", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "String::from\\(v\\.method\\.rsplit\\('\\.'\\)\\.next\\(\\)\\.unwrap_or\\(&v\\.method\\)\\)", + "replacement": "String::from(v.method.split('.').next().unwrap_or(&v.method))", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M11", + "rule": "BR-V5", + "description": "the opened step of every protocol slice is anchored at 0, so the BR-V5 line guard drops it", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": "\\n v\\.open_line,\\n", + "replacement": "\n 0,\n", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M12", + "rule": "BR-V5", + "description": "an end-of-method leak gains a second step that repeats its first", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " ViolationKind::Exit if v\\.line != v\\.open_line => flow\\.push\\(\\(", + "replacement": " ViolationKind::Exit => flow.push((", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M13", + "rule": "BR-V5", + "description": "the late-close hop is never attached", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " if let Some\\(close\\) = v\\.close_line \\{", + "replacement": " if let Some(close) = None:: {", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M14", + "rule": "BR-V5", + "description": "the line guard admits step lines below 1", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " \\.filter\\(\\|\\(line, _\\)\\| \\*line >= 1\\)", + "replacement": " .filter(|(line, _)| *line >= 0)", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M15", + "rule": "BR-V5", + "description": "the barrier step and the exit step swap their labels", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " format!\\(\"barrier \\'\\{\\}\\' fires while it is open\", v\\.barrier_desc\\),", + "replacement": " format!(\"the method exits here via {} while it is open\", v.barrier_desc),", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M16", + "rule": "BR-V5 / OD-6", + "description": "OBL005 stops being anchorless: it takes a line instead of 0", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " let mut f = Finding::new\\(\"\\?\", 0, \"OBL005\", \"protocol obligation\"\\);", + "replacement": " let mut f = Finding::new(\"?\", 1, \"OBL005\", \"protocol obligation\");", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M17", + "rule": "BR-V6", + "description": "OBL005 stops being advisory, so it would fail a build", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " f\\.advisory = true;\\n let mut scope", + "replacement": " f.advisory = false;\n let mut scope", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M18", + "rule": "BR-P3", + "description": "the OBL005 scope list is not sorted", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " scope\\.sort_unstable\\(\\);\\n", + "replacement": "", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M19", + "rule": "BR-P3", + "description": "the OBL005 scope list is rendered with CPython's list separator dropped", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " format!\\(\"\\[\\{\\}\\]\", rendered\\.join\\(\", \"\\)\\)", + "replacement": " format!(\"[{}]\", rendered.join(\",\"))", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M20", + "rule": "BR-P3 (py_repr, the cp5.2 lesson)", + "description": "the scope list quotes unconditionally with ', so an entry holding a quote diverges from CPython's repr", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " let rendered: Vec = items\\.iter\\(\\)\\.map\\(\\|s\\| py_repr\\(Some\\(s\\)\\)\\)\\.collect\\(\\);", + "replacement": " let rendered: Vec = items.iter().map(|s| format!(\"'{s}'\")).collect();", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M21", + "rule": "BR-D2", + "description": "a duplicate protocol name is LAST-wins (both records survive) instead of first-wins", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " if protocols\\.iter\\(\\)\\.any\\(\\|p\\| p\\.name == parsed\\.name\\) \\{\\n continue;\\n \\}\\n", + "replacement": "", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M22", + "rule": "BR-D2", + "description": "a malformed protocols[] entry silences the whole family instead of being skipped as a whole", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " let Ok\\(parsed\\) = protocol::parse_protocol\\(raw\\) else \\{\\n continue;\\n \\};", + "replacement": " let Ok(parsed) = protocol::parse_protocol(raw) else {\n return Vec::new();\n };", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M23", + "rule": "BR-D2", + "description": "a malformed protocol_functions[] entry is dropped from the method list AND takes the rest of the list with it", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " \\.filter_map\\(\\|raw\\| protocol::parse_method\\(raw\\)\\.ok\\(\\)\\)\\n \\.collect\\(\\);", + "replacement": " .map_while(|raw| protocol::parse_method(raw).ok())\n .collect();", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M24", + "rule": "BR-D2 (#294 OD-1)", + "description": "a block that is not a list reads as empty instead of silencing the family", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " Some\\(_\\) => None,\\n \\}\\n\\}\\n\\n/// `v\\.method\\.rsplit", + "replacement": " Some(_) => Some(&[]),\n }\n}\n\n/// `v.method.rsplit", + "expected_catchers": [ + "own-bridge/src/lib.rs::verdict::tests::protocol_blocks_that_are_not_lists_yield_nothing" + ] + }, + { + "id": "M25", + "rule": "BR-V4", + "description": "the resource-kind tag of an OBL finding drifts", + "target": "rust/crates/own-bridge/src/verdict.rs", + "pattern": " let mut f = Finding::new\\(v\\.file\\.clone\\(\\), v\\.line, code, \"protocol obligation\"\\);", + "replacement": " let mut f = Finding::new(v.file.clone(), v.line, code, \"protocol\");", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden", + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M26", + "rule": "P-022 discipline 2", + "description": "corrupt the frozen Layer 3 oracle: an OBL003 is recorded as an OBL004. The replay must fail, or it is not reading the reference's answer", + "target": "tests/fixtures/verdicts/verdict_protocol_exit_codes_and_wordings.verdicts.json", + "pattern": "\"code\": \"OBL003\",\\n \"component\": \"BatchViewModel\",\\n \"event\": \"Suppress\",\\n \"handler\": \"Ret\"", + "replacement": "\"code\": \"OBL004\",\n \"component\": \"BatchViewModel\",\n \"event\": \"Suppress\",\n \"handler\": \"Ret\"", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + }, + { + "id": "M27", + "rule": "P-022 discipline 2", + "description": "corrupt the frozen rendered oracle: the anchorless OBL005 gains a line in the msbuild rendering. The BYTE replay must fail, or the rendered surfaces are carried rather than compared", + "target": "tests/fixtures/verdict_renders/render_protocol_family.renders.json", + "pattern": "\\?\\(0\\): error OBL005", + "replacement": "?(1): error OBL005", + "expected_catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ] + }, + { + "id": "M28", + "rule": "#259 cp4b promotion", + "description": "re-declare the promoted protocol exclusion. The replay must fail BOTH because the pinned exclusion set moved and because the exclusion no longer holds — a promotion that is only announced in prose would survive this", + "target": "tests/fixtures/verdicts/manifest.json", + "pattern": " \"rust_replay_excluded\": \\[\\n \\{\\n \"name\": \"verdict_boundary_line_negative\",", + "replacement": " \"rust_replay_excluded\": [\n {\n \"name\": \"protocol_isloaded_violation\",\n \"reason\": \"re-declared by a mutation campaign\",\n \"rust_refusal\": \"bridge\",\n \"rust_error_contains\": \"obligation protocol\"\n },\n {\n \"name\": \"verdict_boundary_line_negative\",", + "expected_catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ] + } + ] +} diff --git a/docs/evidence/p022-cp4b-2.result.json b/docs/evidence/p022-cp4b-2.result.json new file mode 100644 index 00000000..616336c5 --- /dev/null +++ b/docs/evidence/p022-cp4b-2.result.json @@ -0,0 +1,280 @@ +{ + "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-cp4b-2", + "definition": "docs/evidence/p022-cp4b-2.json", + "definition_sha256": "602633faf9b1ddaa88f102b30a14d79dd17f6f5cfea4ea4188071a1282de4128", + "source_commit": "8c9a2f6b2d63ba4ccb6a144b91dc9a5bdc817feb", + "dirty": false, + "recorded_at": "2026-09-06T20:57:03Z", + "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.5 + }, + "mutations": [ + { + "id": "M01", + "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": 3.5 + }, + { + "id": "M02", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::protocol_blocks_that_are_not_lists_yield_nothing", + "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": 3.4 + }, + { + "id": "M03", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.4 + }, + { + "id": "M04", + "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": 3.3 + }, + { + "id": "M05", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.1 + }, + { + "id": "M06", + "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": 3.2 + }, + { + "id": "M07", + "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": 3.4 + }, + { + "id": "M08", + "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": 3.4 + }, + { + "id": "M09", + "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": 3.6 + }, + { + "id": "M10", + "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": 3.6 + }, + { + "id": "M11", + "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": 3.6 + }, + { + "id": "M12", + "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": 3.5 + }, + { + "id": "M13", + "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": 3.4 + }, + { + "id": "M14", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.3 + }, + { + "id": "M15", + "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": 3.5 + }, + { + "id": "M16", + "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": 3.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": 3.5 + }, + { + "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": 3.4 + }, + { + "id": "M19", + "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": 3.4 + }, + { + "id": "M20", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.2 + }, + { + "id": "M21", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.6 + }, + { + "id": "M22", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.8 + }, + { + "id": "M23", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.5 + }, + { + "id": "M24", + "outcome": "caught", + "catchers": [ + "own-bridge/src/lib.rs::verdict::tests::protocol_blocks_that_are_not_lists_yield_nothing" + ], + "elapsed_seconds": 3.5 + }, + { + "id": "M25", + "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": 3.6 + }, + { + "id": "M26", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.2 + }, + { + "id": "M27", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte" + ], + "elapsed_seconds": 3.0 + }, + { + "id": "M28", + "outcome": "caught", + "catchers": [ + "own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden" + ], + "elapsed_seconds": 3.0 + } + ] +} diff --git a/docs/generated/p022-cp4-census.md b/docs/generated/p022-cp4-census.md index d9202cf0..e0e0cf9e 100644 --- a/docs/generated/p022-cp4-census.md +++ b/docs/generated/p022-cp4-census.md @@ -8,20 +8,19 @@ Computed by `tests/verdict_census.py` and `tests/verdict_render_census.py` (the | measure | value | |--------------------------------------------------------------------------------------|------:| -| goldens — Python's complete truth, one per planned case | 87 | +| goldens — Python's complete truth, one per planned case | 94 | | … swept from `tests/fixtures/ownir` | 22 | | … swept from `tests/fixtures/lowered` | 27 | | … swept from `tests/fixtures/summaries` | 9 | -| … synthetic controls (`manifest.json` cases) | 29 | +| … synthetic controls (`manifest.json` cases) | 36 | | reference refusals over all goldens | 5 | -| 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 | +| reference findings over all goldens | 180 | +| declared Rust exclusions — the executable ledger `rust_replay_excluded` | 6 | | … 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 (goldens minus exclusions) | 79 | +| replayed by Rust (goldens minus exclusions) | 88 | | … reference refusals among them (compared in full) | 5 | -| … findings among them (compared on every `Finding` member) | 152 | +| … findings among them (compared on every `Finding` member) | 174 | 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. @@ -31,8 +30,8 @@ A second family, and a different kind of comparison: its replay compares the **b | measure | value | |-----------------------------------------------------------------------|------:| -| cases — one per BR-V9 rule group, listed exhaustively in the manifest | 8 | +| cases — one per BR-V9 rule group, listed exhaustively in the manifest | 9 | | … 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 | +| rendered lines compared byte-for-byte (4 formats, 2 host severities) | 136 | +| SARIF results compared byte-for-byte (both host severities) | 34 | | 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 b512f396..301086e6 100644 --- a/docs/generated/p022-cp4-mutations.md +++ b/docs/generated/p022-cp4-mutations.md @@ -4,11 +4,11 @@ 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 `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. +Definition: `docs/evidence/p022-cp4-mutations.json` (sha256 `c4ca44f6bbc92cdd…`, 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 | `5553dae32ddf5d22592e11e84c8ed10e8c2fbc3e` | +| recorded at commit | `5085a5152916b1dae260781cf850733095720fe6` | | 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 | @@ -28,26 +28,26 @@ Definition: `docs/evidence/p022-cp4-mutations.json` (sha256 `15b10f36c0d2b46b… | 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 (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`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M08 | BR-V6 | BR-V6 DI001 graded warning like DI002-005 | 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` | | 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`
`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` | +| M11 | BR-M1 | BR-M1 OWN052 note never minted | 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` | +| 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/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` | | 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`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M14 | — | protocol family silently missing: the OBL findings are computed and then dropped. Re-anchored at #259 cp4b — the rule cp4 stated as a REFUSAL (a protocol-bearing document must never get a verdict list with a family missing) is now stated as a WIRING, and this is the same rule attacked on today's tree | 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` | | 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`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| M16 | BR-V6 | BR-V6 source tiering inverted (static warns, injected does not) | 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` | | 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/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::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` | +| M22 | — | OWN050 advisory never minted | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`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/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` | | 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 (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`
`own-shadow/tests/engine.rs::this_engine_reproduces_its_committed_capture` | +| 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/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` | diff --git a/docs/generated/p022-cp4b-mutations.md b/docs/generated/p022-cp4b-mutations.md new file mode 100644 index 00000000..ea42a6af --- /dev/null +++ b/docs/generated/p022-cp4b-mutations.md @@ -0,0 +1,110 @@ + + +# P-022 checkpoint 4b — mutation campaigns + +The obligation-protocol family, measured in two halves: the ANALYSIS (`own-analysis/src/obligation.rs` plus the half of the shared grammar it reads) and the BRIDGE mapping (BR-P3 — codes, wordings, identity derivations, the evidence slice and the tolerant-door rules). 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 4b.1 — the obligation analysis + +Campaign `p022-cp4b-1` — #259 cp4b.1, the obligation-protocol ANALYSIS: the {OPEN, CLOSED} lattice and its min-line provenance, the definite/maybe split, the opens-before-closes-before-barriers leaf order with allow beating barrier, the never-invent asymmetry of an opaque write, the exit rules and the anchor-at-open precedent, the branch join, the loop's silent fixpoint and single emitting pass, the reachability-blind close-line evidence with its strictly-after and barriers-only rules, and the four-part sort key — plus the half of the shared grammar the analysis reads (scope matching, tracked flags, args narrowing, assign-value narrowing and the record defaults). M16 attacks `_emit`'s own guard: the guard around the loop's emitting pass is provably redundant (emission is re-checked inside `_emit`), so no mutation of it can be non-equivalent — measured, not assumed. Two mutations attack the CONTROL rather than the port: they corrupt the frozen oracle in tests/fixtures/obligation_fact_parity.json and require the replay to fail, because a replay that survives a drifting oracle is comparing against nothing. + +Definition: `docs/evidence/p022-cp4b-1.json` (sha256 `ca44ead168221404…`, 33 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp4b-1.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. + +| measure | value | +|------------------------------------------------------------|---| +| recorded at commit | `c7836138bd2fe3b9cfea458a040ac5dda05e890e` | +| 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 | 33 | +| caught | 33 | +| 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 | OwnIR §8 lattice | the merge drops the other path's states: a union becomes 'keep the left' | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M02 | OwnIR §8 lattice | open provenance joins by the LATEST line instead of the earliest | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M03 | OwnIR §8 lattice | the state union intersects OPEN instead of joining it | caught | `own-analysis/src/lib.rs::obligation::tests::an_opaque_write_may_discharge_but_never_opens`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M04 | OwnIR §8 definite/maybe | definite reads 'OPEN is possible' instead of 'OPEN on every path' | caught | `own-analysis/src/lib.rs::obligation::tests::an_opaque_write_may_discharge_but_never_opens`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M05 | obligations._leaf | a re-open moves the provenance forward instead of keeping the earliest site | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M06 | obligations._leaf (allow beats barrier) | the allow list no longer exempts an event from the barrier check | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M07 | OwnIR §8 precision policy | an opaque write DISCHARGES definitely instead of adding CLOSED to the set | caught | `own-analysis/src/lib.rs::obligation::tests::an_opaque_write_may_discharge_but_never_opens`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M08 | OwnIR §8 precision policy | an opaque write to an UNTRACKED member discharges the obligation | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M09 | obligations._exit | exit_barriers is ignored: return/throw always cross | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M10 | OwnIR §8 anchor-at-open (the OWN001 precedent) | an end-of-method leak anchors at 0 instead of at the open site | caught | `own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M11 | obligations.walk_seq | a sequence keeps walking past an event that left the method | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M12 | obligations.walk (if) | ONE dead arm ends the method instead of both | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M13 | obligations.walk (if) | the merge keeps only the then-arm instead of joining both | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M14 | obligations.walk (while) — zero iterations | the fixpoint forgets the header, so a loop can no longer run zero times | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M15 | obligations.walk (while) — two-phase emission | the fixpoint iterations emit, so a barrier in a loop reports more than once | caught | `own-analysis/src/lib.rs::obligation::tests::a_barrier_in_a_loop_reports_exactly_once`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M16 | obligations._emit (the silent phase) | the silent phase does not suppress emission, so a loop's fixpoint iterations report. Attacks `_emit`'s guard rather than the redundant one around the emitting pass: with `_emit` guarded, removing `if !self.silent` around `walk_seq` is an EQUIVALENT mutant (measured — it survived), because a nested pass under an enclosing silent phase emits nothing either way | caught | `own-analysis/src/lib.rs::obligation::tests::a_barrier_in_a_loop_reports_exactly_once`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M17 | obligations._close_lines | close lines inside a loop body are not collected as evidence | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M18 | obligations.check_protocols (late close) | the late close is 'at or after' the barrier instead of strictly after | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M19 | obligations.check_protocols (late close) | the late-close hop is attached to exit leaks instead of barrier crossings | caught | `own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open`
`own-analysis/src/lib.rs::obligation::tests::open_barrier_close_is_one_definite_crossing`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M20 | obligations.check_protocols (sort key) | violations sort by line before file | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M21 | obligations.check_protocols (sort key) | the protocol tiebreak is dropped from the sort key | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M22 | obligations.check_protocols (sort key) | the barrier-description tiebreak is dropped from the sort key | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M23 | obligations.unmatched_scopes | an UNSCOPED protocol is reported as a dead rule | caught | `own-analysis/src/lib.rs::obligation::tests::a_dead_scope_is_reported_and_an_unscoped_protocol_is_not`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M24 | OwnIR §8 scope (suffix match) | a scope entry matches anywhere in the method name instead of as a suffix | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity`
`own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value` | +| M25 | OwnIR §8 scope (empty = every method) | an empty scope is inverted: a scoped protocol applies everywhere | caught | `own-analysis/src/lib.rs::obligation::tests::a_barrier_in_a_loop_reports_exactly_once`
`own-analysis/src/lib.rs::obligation::tests::a_dead_scope_is_reported_and_an_unscoped_protocol_is_not`
`own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open`
`own-analysis/src/lib.rs::obligation::tests::an_opaque_write_may_discharge_but_never_opens`
`own-analysis/src/lib.rs::obligation::tests::open_barrier_close_is_one_definite_crossing`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity`
`own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value` | +| M26 | OwnIR §8 tracked flags | tracks_target ignores the member name, so every assign target is tracked | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity`
`own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value` | +| M27 | OwnIR §8 precision policy (unknown argument) | a call with an UNKNOWN argument matches an args-narrowed matcher | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity`
`own-ir/src/lib.rs::protocol::tests::a_narrowed_call_matcher_never_matches_an_unknown_argument` | +| M28 | OwnIR §8 matcher (assign value narrowing) | an assign matcher ignores the written value, so a close also opens | caught | `own-analysis/src/lib.rs::obligation::tests::a_barrier_in_a_loop_reports_exactly_once`
`own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open`
`own-analysis/src/lib.rs::obligation::tests::an_opaque_write_may_discharge_but_never_opens`
`own-analysis/src/lib.rs::obligation::tests::close_before_the_barrier_is_clean`
`own-analysis/src/lib.rs::obligation::tests::open_barrier_close_is_one_definite_crossing`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M29 | OwnIR §8 grammar (exit_barriers default) | an absent exit_barriers defaults to false instead of true | caught | `own-analysis/src/lib.rs::obligation::tests::a_dead_scope_is_reported_and_an_unscoped_protocol_is_not`
`own-analysis/src/lib.rs::obligation::tests::an_end_of_method_leak_anchors_at_the_open`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity`
`own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value`
`own-ir/tests/validation_replay.rs::the_two_loaders_accept_the_same_language` | +| M30 | OwnIR §8 grammar (scope construction) | a validated scope entry is checked and then dropped, so every scope is empty | caught | `own-analysis/src/lib.rs::obligation::tests::a_dead_scope_is_reported_and_an_unscoped_protocol_is_not`
`own-analysis/tests/obligation_parity.rs::obligation_fact_parity`
`own-ir/src/lib.rs::protocol::tests::a_protocol_record_becomes_its_typed_value` | +| M31 | OwnIR §8 grammar (file default) | an absent protocol-function 'file' defaults to the empty string, not '?' | caught | `own-ir/src/lib.rs::protocol::tests::a_method_record_becomes_its_event_tree` | +| M32 | P-022 discipline 2 (the control is the thing under test) | corrupt the frozen oracle: the anchor of the leak whose open carries no line moves off 0. The replay must fail, or it is not reading the reference's answer | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | +| M33 | P-022 discipline 2 (the control is the thing under test) | corrupt the frozen oracle's dead-rule list. The replay must fail, or the unmatched_scopes half is carried without being compared | caught | `own-analysis/tests/obligation_parity.rs::obligation_fact_parity` | + +## checkpoint 4b.2 — the bridge mapping (BR-P3) + +Campaign `p022-cp4b-2` — #259 cp4b.2, the BRIDGE half of the obligation family: the (kind, definite) -> OBL001-004 table, all four line-free wordings and both exit_desc branches, the component/handler derivations, every step of the opened -> barrier (or exit) -> late-close slice with its line guard, the anchorless advisory OBL005 with its sorted CPython-repr scope list, and the three tolerant-door rules (skip a malformed entry whole, first-wins on a duplicate name, a non-list block silences the family). Three mutations attack the CONTROLS rather than the port: two corrupt the frozen Layer 3 and rendered oracles and require their replays to fail, and one re-declares the exclusion this checkpoint promoted — a promotion that were only announced in prose would survive it. + +Definition: `docs/evidence/p022-cp4b-2.json` (sha256 `602633faf9b1ddaa…`, 28 mutations). Replay on a clean tree with `python scripts/mutate_campaign.py --campaign docs/evidence/p022-cp4b-2.json --run`; the recorded run is raw outcomes and provenance, the counts below are derived from it. + +| measure | value | +|------------------------------------------------------------|---| +| recorded at commit | `8c9a2f6b2d63ba4ccb6a144b91dc9a5bdc817feb` | +| 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 | 28 | +| caught | 28 | +| 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-P3 | the barrier row of the code table loses its definite/maybe split | 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` | +| M02 | BR-P3 | the exit row of the code table loses its definite/maybe split | caught | `own-bridge/src/lib.rs::verdict::tests::protocol_blocks_that_are_not_lists_yield_nothing`
`own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M03 | BR-P3 | a barrier crossing is coded as an exit leak | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M04 | BR-V4 | the barrier wording states 'may still be open' for a definite crossing | 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` | +| M05 | BR-V4 | the exit wording states 'is not closed' for a maybe leak | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M06 | BR-V4 | every exit words itself as the fall-off, never naming the exit site | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M07 | BR-V4 | the fall-off branch is never taken: a leak off the end words itself as 'exits via end of method' | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M08 | BR-V4 | the barrier wording names the barrier where it should name the close | 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` | +| M09 | BR-P3 | component takes the LAST dotted segment instead of the second-to-last | 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` | +| M10 | BR-P3 | handler takes the FIRST dotted segment instead of the last | 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` | +| M11 | BR-V5 | the opened step of every protocol slice is anchored at 0, so the BR-V5 line guard drops it | 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` | +| M12 | BR-V5 | an end-of-method leak gains a second step that repeats its first | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M13 | BR-V5 | the late-close hop is never attached | 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` | +| M14 | BR-V5 | the line guard admits step lines below 1 | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M15 | BR-V5 | the barrier step and the exit step swap their labels | 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` | +| M16 | BR-V5 / OD-6 | OBL005 stops being anchorless: it takes a line instead of 0 | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M17 | BR-V6 | OBL005 stops being advisory, so it would fail a build | 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-P3 | the OBL005 scope list is not sorted | 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-P3 | the OBL005 scope list is rendered with CPython's list separator dropped | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte`
`own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M20 | BR-P3 (py_repr, the cp5.2 lesson) | the scope list quotes unconditionally with ', so an entry holding a quote diverges from CPython's repr | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M21 | BR-D2 | a duplicate protocol name is LAST-wins (both records survive) instead of first-wins | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M22 | BR-D2 | a malformed protocols[] entry silences the whole family instead of being skipped as a whole | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M23 | BR-D2 | a malformed protocol_functions[] entry is dropped from the method list AND takes the rest of the list with it | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M24 | BR-D2 (#294 OD-1) | a block that is not a list reads as empty instead of silencing the family | caught | `own-bridge/src/lib.rs::verdict::tests::protocol_blocks_that_are_not_lists_yield_nothing` | +| M25 | BR-V4 | the resource-kind tag of an OBL finding drifts | 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` | +| M26 | P-022 discipline 2 | corrupt the frozen Layer 3 oracle: an OBL003 is recorded as an OBL004. The replay must fail, or it is not reading the reference's answer | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | +| M27 | P-022 discipline 2 | corrupt the frozen rendered oracle: the anchorless OBL005 gains a line in the msbuild rendering. The BYTE replay must fail, or the rendered surfaces are carried rather than compared | caught | `own-bridge/tests/renders.rs::replays_every_rendered_surface_byte_for_byte` | +| M28 | #259 cp4b promotion | re-declare the promoted protocol exclusion. The replay must fail BOTH because the pinned exclusion set moved and because the exclusion no longer holds — a promotion that is only announced in prose would survive this | caught | `own-bridge/tests/verdicts.rs::replays_every_case_to_its_golden` | diff --git a/docs/generated/p022-cp5-inventory.md b/docs/generated/p022-cp5-inventory.md index a427832d..9190d85b 100644 --- a/docs/generated/p022-cp5-inventory.md +++ b/docs/generated/p022-cp5-inventory.md @@ -8,7 +8,7 @@ Checkpoint 4 proved identity, anchor, kind and tiering over the replayed set ([c ## 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. +`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 (BR-P3), synthesized by the bridge from a violation the obligation analysis owns. | ledger row | surface | what it is | all goldens | replayed | |---|---|---|---:|---:| @@ -44,16 +44,20 @@ Checkpoint 4 proved identity, anchor, kind and tiering over the replayed set ([c | `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_own050` | bridge | OWN050 unresolved-reference note | 5 | 5 | | `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 | +| `di001_message` | core-analysis | DI001 captive message (di.py) | 12 | 10 | | `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 | +| `eff001_message` | core-analysis | EFF001 storm message (effects.py) | 8 | 6 | +| `obl001_barrier_definite` | bridge-protocol | OBL001: a barrier fires while the obligation is open on every path | 4 | 4 | +| `obl002_barrier_maybe` | bridge-protocol | OBL002: a barrier fires while it is open on some path | 1 | 1 | +| `obl003_exit_definite` | bridge-protocol | OBL003: the method leaves while the obligation is open on every path | 8 | 8 | +| `obl004_exit_maybe` | bridge-protocol | OBL004: the method leaves while it is open on some path | 2 | 2 | +| `obl005_dead_rule` | bridge-protocol | OBL005: a scoped protocol matched no reported method (advisory) | 3 | 3 | ### Wording tails @@ -63,9 +67,11 @@ Each is its own degradation rule inside an analysis message — the tail is drop |---|---|---|---:|---:| | `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_consumed_absent` | wording tail | no consuming-constructor tail (location unknown) | 9 | 7 | | `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 | +| `tail_obl_exit_falls_off` | wording tail | `the method falls off the end` — a leak off the end has no exit site to name | 3 | 3 | +| `tail_obl_exit_via` | wording tail | `'' exits via return|throw` — the exit site is named | 7 | 7 | ## BR-V5 — evidence slices @@ -74,14 +80,14 @@ One row per `related`/`flow` family; a slice matching no family (or two) fails t | 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_2` | flow | DI retention path, captor → captured | 20 | 19 | | `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 | +| `effect_flow` | flow | EFF001 re-run site → identity-mint site | 5 | 4 | | `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 | @@ -89,8 +95,11 @@ One row per `related`/`flow` family; a slice matching no family (or two) fails t | `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 | +| `protocol_flow_3` | flow | OBL opened → barrier → late close | 4 | 4 | +| `protocol_flow_open_barrier` | flow | OBL opened → barrier, no close after it | 1 | 1 | +| `protocol_flow_open_exit` | flow | OBL opened → the exit it leaked through | 6 | 6 | +| `protocol_flow_open_only` | flow | OBL opened, and nothing else: a leak off the END anchors AT the open, so its second step would repeat the first | 2 | 2 | +| `protocol_flow_exit_only` | flow | OBL exit alone: the open carries no line (< 1) and its step is dropped | 1 | 1 | ### Degradations @@ -98,13 +107,14 @@ The rules that produce an EMPTY slice: a step whose line is unknown is omitted, | 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 | +| `di_consumer_related_dropped` | related | DI001/2/3 with no consuming-constructor related (line < 1) | 9 | 7 | | `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 | +| `protocol_flow_absent` | flow | an OBL finding with no slice at all: the anchorless OBL005 by design, and a leak whose only step has an unknown line | 4 | 4 | ## BR-V9 — rendered surfaces @@ -129,17 +139,17 @@ Coverage is matched out of the `tests/fixtures/verdict_renders/` family's `pins` | `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_note` | sarif | an advisory renders as `note` | 2 | 2 | | `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_region_omitted` | sarif | `region` omitted entirely for line < 1 | 2 | 2 | | `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_code_flows` | sarif | `codeFlows` from the ordered `flow` | 2 | 2 | | `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-shadow-census.md b/docs/generated/p022-shadow-census.md index b89419ec..3522671e 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` | 29 | -| **total** | **90** | +| `tests/fixtures/verdicts` | 36 | +| **total** | **97** | 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 | 90 | -| tamper controls (one changed character per document, refusal required) | 90 | +| documents captured and digest-pinned | 97 | +| tamper controls (one changed character per document, refusal required) | 97 | | 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,7 +51,7 @@ 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 | 27 | 0 | +| `rust-own-bridge` | 21 | 6 | 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 @@ -74,7 +74,6 @@ declares rather than a disagreement it stumbled into: | case | layer | statuses | |---|---|---| -| `protocol_isloaded_violation` | `verdicts` | python-ownlang: produced, rust-own-bridge: refused | | `verdict_door_effect_deps_not_strings` | `lowered` | python-ownlang: produced, rust-own-bridge: refused | | `verdict_door_effect_deps_not_strings` | `summaries` | python-ownlang: produced, rust-own-bridge: refused | | `verdict_door_effect_deps_not_strings` | `verdicts` | python-ownlang: produced, rust-own-bridge: refused | @@ -88,7 +87,7 @@ ordering semantics are **declared** rather than normalized away. | surface | count | |---|---| | trace layers projected (both engines, every artifact) | 54 | -| addressed steps | 254 | +| addressed steps | 256 | | of those, handle addresses standing in for a mint counter | 12 | The normalization is proven on the property it exists for, over the whole diff --git a/docs/notes/p022-bridge-verdict-checkpoint4.md b/docs/notes/p022-bridge-verdict-checkpoint4.md index 07d100b3..bf7902d3 100644 --- a/docs/notes/p022-bridge-verdict-checkpoint4.md +++ b/docs/notes/p022-bridge-verdict-checkpoint4.md @@ -78,6 +78,13 @@ without regenerating a golden. ### The unmeasured set is named, not hidden +> **Read the protocol paragraphs below as history.** #259 checkpoint 4b +> ([note](p022-bridge-verdict-checkpoint4b.md)) ported the obligation +> analysis, wired it through the bridge and **promoted both protocol +> documents out of `rust_replay_excluded`**. Where this note says the OBL +> analysis is not ported, or that a protocol-bearing document is refused, +> it describes the state at the time it was written. + Each exclusion is an entry in `rust_replay_excluded` with a reason and an expectation the replay executes (`rust_refusal: bridge` + an error substring, or `door`); the set is also pinned by name, and an exclusion that stops diff --git a/docs/notes/p022-bridge-verdict-checkpoint4b.md b/docs/notes/p022-bridge-verdict-checkpoint4b.md new file mode 100644 index 00000000..ec74710e --- /dev/null +++ b/docs/notes/p022-bridge-verdict-checkpoint4b.md @@ -0,0 +1,436 @@ +# P-022 step 6b (#259) — checkpoint 4b: the obligation-protocol analysis (OBL001–005) + +> Status: **checkpoint 4b complete — Layer 3 parity over the measured set, +> protocol family included.** 4b.0 the inventory, 4b.1 the analysis and its +> fact-parity family, 4b.2 the bridge and the promotion, 4b.3 the status +> surfaces. This note is both the plan and the completeness ledger: every +> behaviour of `ownlang/obligations.py` and of the bridge's BR-P3 mapping, +> named with what pins it, plus what the corpus reached before and what it +> reaches now. Counts are not typed here: they live in the generated fragments +> ([census](../generated/p022-cp4-census.md), +> [surface inventory](../generated/p022-cp5-inventory.md), +> [campaigns](../generated/p022-cp4b-mutations.md)) and the prose links. + +Checkpoints 1–5 ([cp4](p022-bridge-verdict-checkpoint4.md), +[cp5](p022-bridge-verdict-checkpoint5.md)) took `own_bridge::check_facts` to +Layer 3 parity over the measured set, on the full `Finding` and on the rendered +surfaces. One analysis family was missing from the Rust side entirely: the +obligation protocols. The bridge **refused** a document with a non-empty +`protocols[]` (`own-bridge/src/verdict.rs::refuse_protocols`) rather than +return a verdict list with a family silently absent, and the two reference +documents `protocol_isloaded_clean` / `protocol_isloaded_violation` sat in the +verdict ledger's `rust_replay_excluded` with the executable expectation +"`check_facts` errors, and the error contains `obligation protocol`". + +4b removed that refusal. It is its own checkpoint rather than a fourth job for +cp5 because `obligations.py` is a path-sensitive analysis of its own — a +lattice, a walker, a matcher language — and cp5's scope was messages, evidence +and rendering. + +## 0. Scope, and the three things 4b is not + +**In scope.** The v1 analysis exactly as `ownlang/obligations.py` has it; the +BR-P3 mapping to `Finding`s exactly as `ownlang/ownir.py::_protocol_findings` / +`_protocol_message` have it; a fact-parity family at the analysis level; the +synthetic Layer 3 cases the corpus is missing; the promotion of the two +excluded documents; the status surfaces. + +**Not in scope, by declaration:** + +1. **P-025 phase 3** — interprocedural obligation summaries. v1 is an + intramethod walk and a callee that flips a tracked flag is invisible to it; + that is the reference's behaviour, and reproducing it is the job. +2. **The coordinate-domain decision.** The `u32` line-domain exclusions stay + exactly as cp4 and cp5 left them. 4b touches `rust_replay_excluded` for the + two protocol promotions and for nothing else. +3. **The `own-shadow` reducer's Layer 3 scope.** After 4b the Rust engine + *produces* the verdict layer for a protocol-bearing document instead of + refusing it, so that document's artifact, trace and reduction change. The + reducer's scope stays `["lowered", "summaries"]`: comparing end diagnostics + is #260's acceptance and moving that line is #260's decision, not 4b's. + +## 1. What the two existing reference documents reached (the starting point) + +Both were excluded from the Rust replay, so over the **replayed** set the +protocol family was at nothing: the surface-inventory rows `obl_message`, +`protocol_flow` and `protocol_flow_3` all reported no replayed coverage, each +with the recorded disposition "row 4b". Over **all goldens** — Python's +complete truth, which is what 4b inherited as its starting corpus — the picture +was narrower than the row names suggest, and it was measured rather than +assumed +(`tests/verdict_surface_inventory.py`, rendered into +[`p022-cp5-inventory.md`](../generated/p022-cp5-inventory.md)): + +* `obl_message` matches the OBL family as **one** row (its pattern is `.*`, + because cp5 had no reason to split a family it declared out of scope). The + single golden finding behind it is `protocol_isloaded_violation`'s OBL001. +* `protocol_flow_3` — the three-step slice *opened → barrier → late close* — is + reached by that same finding. +* `protocol_flow` — the two-step slice *opened → barrier* with no late close — + is reached by **nothing**. The corpus has no golden for it at all. + +`protocol_isloaded_clean` contributes no finding, which is the point of it: it +is the silence twin, and a vacuous replay would have "matched" it. + +So the corpus reached exactly one shape: an OBL001 barrier crossing, definite, +inside an `if` branch, with a late close, on a dotted method name matched by a +`Type.Method` scope suffix, past an allow-listed call and a call the protocol +does not name. Everything else in §2 and §3 below had **no golden**: OBL002, +OBL003, OBL004, OBL005, every exit anchor, every loop, `exit_barriers: false`, +the opaque-write asymmetry, args-narrowing and the unknown argument, exact +scope matching, duplicate protocol names, the malformed-entry skip, the +non-list blocks, the sort key, and the two-step slice itself. Every one of +them has a control now, and the inventory counts them over the replayed set — +the `.*` placeholder row is gone, replaced by five wordings, two exit_desc +tails, five precise slice shapes and an empty-slice degradation. + +## 2. The analysis, behaviour by behaviour (`ownlang/obligations.py`) + +Each row is a case in the analysis-level fact-parity family +(`tests/fixtures/obligation_fact_parity.json`, generator +`tests/test_obligation_fact_parity.py`, Rust replay in `own-analysis/tests`), +seeded from `tests/test_obligations.py` §1. The family freezes each violation +whole — `protocol`, `method`, `file`, `line`, `kind`, `definite`, `open_line`, +`barrier_desc`, `close_line` — and the dead-protocol list beside it, so a +divergence names the member it is in. + +### 2.1 The lattice + +| # | Behaviour | Reference | +|---|---|---| +| A1 | state is a **set** over `{OPEN, CLOSED}`; the join is set union | `_join` | +| A2 | open provenance joins by **minimum line** across the paths where it is open | `_join`, `min(lines)` | +| A3 | bottom is `(∅, None)`; a dead branch contributes bottom to a merge | `_BOTTOM`, `IfEv` arm | +| A4 | a walk starts `({CLOSED}, None)` — a method begins with nothing owed | `_Walker.run` | +| A5 | `definite` is `states == {OPEN}` — open on every path, not merely on one | `_emit` | + +### 2.2 Matching + +| # | Behaviour | Reference | +|---|---|---| +| B1 | an `assign` matcher matches on `target`; `value=None` matches any written value, **including an opaque write** | `Matcher.matches` | +| B2 | a `call` matcher matches on `callee`; empty `args` matches every call | `Matcher.matches` | +| B3 | a non-empty `args` narrows: an argument outside the set does not match | `Matcher.matches` | +| B4 | a call with an **unknown** argument does not match a narrowed matcher — never invent a crossing we cannot prove | `Matcher.matches` (`ev.arg is not None`) | +| B5 | `describe()`: `T = ...` (opaque), `T = true` / `T = false` (lowercased Python bool), `f()` | `Matcher.describe` | +| B6 | `applies_to`: no `scope.methods` matches every reporting method | `Protocol.applies_to` | +| B7 | `applies_to`: an **exact** name or a trailing `.Type.Method` suffix | `Protocol.applies_to` | +| B8 | `tracks_target`: a flag named by an `assign` matcher in `opens` **or** `closes` | `Protocol.tracks_target` | + +### 2.3 The walk over one leaf event + +The order is the semantics — opens, then closes, then barriers: + +| # | Behaviour | Reference | +|---|---|---| +| C1 | an opening event wins over everything: state becomes `{OPEN}` | `_leaf` | +| C2 | **re-opening** keeps the earliest open site as provenance (`min(prev, line)`) | `_leaf` | +| C3 | a closing event discharges: `({CLOSED}, None)` — the provenance is dropped with it | `_leaf` | +| C4 | a barrier is only considered while `OPEN` is possible | `_leaf` | +| C5 | **allow beats barrier**: an allow-listed event never crosses | `_leaf` | +| C6 | the **first** matching barrier emits, then the scan stops | `_leaf` `break` | +| C7 | `barrier_desc` for a call is `callee(arg)`, and `callee()` when the argument is unknown | `_leaf` | +| C8 | `barrier_desc` for an assign barrier is `target = ...` | `_leaf` | +| C9 | an **opaque** write to a tracked flag while open adds `CLOSED` — it may have discharged | `_leaf` | +| C10 | an opaque write while **closed** never opens (the never-invent asymmetry) | `_leaf` (guarded by `OPEN in states`) | +| C11 | an opaque write to an **untracked** member is inert either way | `_leaf`, `tracks_target` | +| C12 | a call the protocol does not name is neutral — no discharge, no crossing | `_leaf` falls through | + +### 2.4 Exits + +| # | Behaviour | Reference | +|---|---|---| +| D1 | `return` while open emits at the **return** line, desc `return`; the path dies | `walk`, `_exit` | +| D2 | `throw` while open emits at the **throw** line, desc `throw`; the path dies | `walk`, `_exit` | +| D3 | falling off the end while open emits desc `end of method`, anchored at the **open** site (the OWN001 anchor-at-acquire precedent) | `run` | +| D4 | an end-of-method leak with **unknown** provenance anchors at `0` | `run` (`anchor = … else 0`) | +| D5 | `exit_barriers: false` silences every exit, and only exits — barriers still fire | `_exit` | +| D6 | a sequence stops at the first event that leaves the method | `walk_seq` | + +### 2.5 Control flow + +| # | Behaviour | Reference | +|---|---|---| +| E1 | `if` walks both arms from the same state and joins them | `walk` | +| E2 | an arm that has left the method contributes bottom, not its state | `walk` (`s1 if a1 else _BOTTOM`) | +| E3 | both arms dead ⇒ the whole `if` is dead | `walk` | +| E4 | a barrier inside one arm stays **definite** — the branch is where flow goes, not where the obligation becomes conditional | falls out of A5 | +| E5 | `while` iterates its body to a **silent** local fixpoint on the header state | `walk` | +| E6 | then re-walks the body **once**, emitting, on the converged header — so a barrier in a loop reports exactly once | `walk` | +| E7 | the emitting pass is skipped while an enclosing loop is still silent (nested loops still emit once) | `walk` (`if not self.silent`) | +| E8 | the loop's exit state is the **header**: zero iterations is always possible | `walk` | + +### 2.6 Evidence and ordering + +| # | Behaviour | Reference | +|---|---|---| +| F1 | close lines are collected over the whole tree, **reachability ignored**, recursing into `if`/`while` | `_close_lines` | +| F2 | the late-close hop is the **earliest** close strictly after the violation line | `check_protocols` | +| F3 | the hop is attached to **barrier** crossings only — an exit leak has no barrier to be late for | `check_protocols` | +| F4 | violations sort by `(file, line, protocol, barrier_desc)` | `check_protocols` | +| F5 | protocols × methods: every protocol is checked against every in-scope method, protocols do not interfere | `check_protocols` | + +### 2.7 Dead rules + +| # | Behaviour | Reference | +|---|---|---| +| G1 | a protocol with a non-empty scope matching no reported method is a dead rule | `unmatched_scopes` | +| G2 | an **unscoped** protocol is never a dead rule, even with no methods at all | `unmatched_scopes` | + +## 3. The bridge mapping (BR-P3, `ownlang/ownir.py`) + +Each row is a synthetic Layer 3 case under the frozen verdict ledger +(`verdict_protocol_*`, insertion-stable: no existing record rewritten), unless +`protocol_isloaded_violation` already reached it. The third column is what the +corpus reached **before** 4b; every row is covered now. + +| # | Behaviour | Reached before 4b? | +|---|---|---| +| H1 | `(barrier, definite)` → **OBL001** | yes | +| H2 | `(barrier, maybe)` → **OBL002** | no | +| H3 | `(exit, definite)` → **OBL003** | no | +| H4 | `(exit, maybe)` → **OBL004** | no | +| H5 | barrier message, definite: *"is still open when barrier '…' fires in '…' — '…' must happen first"* | yes | +| H6 | barrier message, maybe: *"may still be open (open on some path)"* | no | +| H7 | exit message, definite: *"is not closed"* | no | +| H8 | exit message, maybe: *"may not be closed (open on some path)"* | no | +| H9 | `exit_desc` for `return` / `throw`: *"'M' exits via return"* | no | +| H10 | `exit_desc` for the fall-off: *"the method falls off the end"* | no | +| H11 | every message is **line-free** (OwnAudit fingerprints on `(path, rule, message)`) | yes | +| H12 | `component` = the second-to-last dotted segment (`rsplit(".", 2)[-2]`) | yes | +| H13 | `component` = the whole name when it carries no dot | no | +| H14 | `handler` = the last dotted segment | yes | +| H15 | `event` = the protocol name; `kind` = `protocol obligation`; `column`/`severity` absent | yes | +| H16 | flow step 1: *opened here (``)* | yes | +| H17 | flow step 2 for a barrier: *barrier '…' fires while it is open* | yes | +| H18 | flow step 2 for an exit, **only when `line != open_line`** — so an end-of-method leak has no second step | no | +| H19 | flow step 3: *closed here — after the barrier has already fired* | yes | +| H20 | steps with `line < 1` are dropped (BR-V5) | no | +| H21 | **OBL005**: advisory, anchorless (`file="?"`, `line=0`, `component="?"`, `handler=""`) | no | +| H22 | the OBL005 message interpolates `sorted(p.methods)` as a **Python list repr** | no | +| H23 | `protocols` / `protocol_functions` that are not lists ⇒ no findings | no | +| H24 | a malformed `protocols[]` entry is **skipped**, never coerced | no | +| H25 | a malformed `protocol_functions[]` entry is skipped | no | +| H26 | a duplicate protocol name resolves **first-wins** on the tolerant door | no | +| H27 | no parseable protocol ⇒ no findings, even with methods present | no | +| H28 | protocol findings are appended after effects and before OWN050 (BR-V1) | yes (vacuously — one family present) | + +### 3.1 Where the port puts each half + +`obligations.py` splits cleanly along BR-B1, and the port follows it: + +| half | reference | port | +|---|---|---| +| the acceptance grammar (ported at cp1) | `parse_protocol` / `parse_matcher` / `parse_events` / `parse_method` | `own-ir/src/protocol.rs` — now also the typed constructor | +| the lattice, the walker, `check_protocols`, `unmatched_scopes` | `obligations.py` | `own-analysis/src/obligation.rs`, beside `di.rs` / `effect.rs` | +| the `(kind, definite)` → code table, the messages, the slice | `ownir.py::_protocol_findings` / `_protocol_message` | `own-bridge/src/verdict.rs`, where `refuse_protocols` was | + +The typed `Protocol` / `Matcher` / `MethodEvents` / event values are built by +**one** implementation of the grammar with two consumers, not a second parser +in `own-analysis`: `own-ir/src/protocol.rs` grows from *validate* to *validate +and construct*, the strict door keeps taking only the identity it needs, and +the analysis takes the value. Two interpretations of one grammar is the same +drift as two censuses. No new crate: `own-analysis → own-ir` is an existing +allowed edge (`own-diagnostics/tests/dag.rs`), and no core crate depends on the +bridge. + +The strict door's error texts do not move by a byte. The cp1 ledger +(`tests/fixtures/ownir_validation.json`) carries protocol controls with their +accept/reject verdict and their category; red there is a stop, not a thing to +re-baseline. + +### 3.2 The door difference, and where each rule is pinned (OD-1) + +The typed Rust constructor (`OwnIr::from_json`) keeps `protocols[]` and +`protocol_functions[]` as raw values and checks only their nesting depth; the +protocol grammar runs in the **strict** door alone. So, unlike the effect-entry +skip — which the typed `Effect` shape makes unreachable, the OD-1 case cp4 +recorded — the tolerant door's protocol rules are reachable **end to end**: a +malformed `protocols[]` entry is skipped (H24), a malformed +`protocol_functions[]` entry is skipped without taking the rest of the list +with it (H25), and a duplicate name resolves first-wins (H26). All three are +replayed against Python's golden by the synthetic case +`verdict_protocol_tolerant_door_rules`, and the cp4b.2 campaign's M21–M23 are +caught by that replay, not by a unit test. Only the non-list block rule (H23) +is pinned at the unit level (`protocol_blocks_that_are_not_lists_yield_nothing`, +M24), in the shape cp4 established for the effect skip. Nothing here coerces a +document into shape: a record the grammar refuses is dropped whole on the +tolerant path, and the strict door still refuses the same document at load — +its 216 controls are unchanged by 4b. (An earlier draft of this section, written +at 4b.0 before the case existed, said the three rules were unreachable; the +tree proved otherwise and this paragraph follows the tree.) + +## 4. The surfaces 4b moved + +| surface | change | +|---|---| +| `tests/fixtures/verdicts/manifest.json` | the two protocol entries left `rust_replay_excluded`; the new `verdict_protocol_*` cases joined `cases` (insertion-stable) | +| `own-bridge/tests/verdicts.rs` | the pinned exclusion set shrinks by the two promoted names — the deliberate contract change this checkpoint exists for | +| `tests/fixtures/verdicts/protocol_isloaded_*.verdicts.json` | **not regenerated**. They are Python's truth as committed; the replay must converge on them as they are | +| `tests/verdict_surface_inventory.py` | `obl_message` becomes per-code rows counted over the replayed set; `protocol_flow` / `protocol_flow_3` lose the "4b, not cp5" disposition and must each carry replayed coverage | +| `docs/generated/p022-cp4-census.md` | the "refused … `obligation protocol`" line disappears (the fragment is rendered, so it rebuilds itself) | +| `tests/fixtures/repro/protocol_isloaded_violation.*` | artifact, trace and reduction regenerate: the Rust engine's `verdicts` layer moves from `refused` to `produced`. The only golden 4b regenerates, and for a stated reason — the same shape of change #339 recorded when the shadow verdict capture moved `partial` → `full` | +| `docs/generated/p022-shadow-census.md` | rebuilds with one fewer refused layer envelope | +| `spec/BridgeBehaviorMatrix.md` | the protocol rows become `L3 ✅`; the paragraph in (e) that names the protocol family as outside the replayed set is rewritten | +| `spec/Bridge.md` §6 | "OBL analysis not ported" leaves the `rust_replay_excluded` description | +| `docs/proposals/P-022-rust-core-migration.md` | row 4b → complete; the preferred queue moves to the coordinate decision | +| `docs/notes/p022-bridge-verdict-checkpoint4.md`, `…5.md` | a "read as history" banner where they say the protocols are not ported | +| `scripts/render_checkpoint_status.py`, `tests/test_checkpoint_status.py` | the 4b campaigns registered in both | + +## 5. Stop conditions + +Recorded before the work, so that hitting one is a decision and not a +temptation. **None was hit**: no golden and no `ownlang/` line was touched to +make something agree, no divergence looked like a reference bug, every +semantics question was answered by `obligations.py` or `spec/OwnIR.md` §8, the +two doors needed no new ledger member (the one door-unreachable rule is pinned +at the raw-document level, as cp4 established), and every synthetic case built +inside the existing grammar. + +* wanting to touch a golden or `ownlang/` to make something agree; +* a divergence that looks like a **Python** bug — P-025's standing red line is + *never invent a violation*, so it gets reported, not fixed; +* needing semantics that are in neither `obligations.py` nor `spec/OwnIR.md` + §8; +* the strict door and the tolerant path diverging in a way that needs a new + ledger member; +* a synthetic case that cannot be built without changing the grammar. + +## 6. What landed, and what it cost + +### 6.1 The four commits + +| checkpoint | what it did | +|---|---| +| **4b.0** | this note: the behaviour ledger, and the measured picture of what the corpus reached | +| **4b.1** | `own-analysis/src/obligation.rs`; `own-ir/src/protocol.rs` from validate-only to validate-and-construct; `tests/test_obligation_fact_parity.py` + its fixture + `own-analysis/tests/obligation_parity.rs`; campaign `p022-cp4b-1` | +| **4b.2** | `refuse_protocols` removed and BR-P3 mapped in its BR-V1 place; both exclusions promoted; seven synthetic Layer 3 cases and one rendered case; the surface inventory's OBL rows made real; the shadow artifact regenerated; campaign `p022-cp4b-2` | +| **4b.3** | the status surfaces: P-022 row 4b and its queue, `spec/Bridge.md` §6, the behavior matrix, the cp4/cp5 history banners, the proposals index, and both campaigns registered in the renderer and in the replayability gate | + +### 6.2 Python source of truth, and the frozen fixtures + +`ownlang/` is **unchanged** — not one line of `obligations.py` or `ownir.py`. +Every Python file 4b adds is an observer or a generator, in the shape +`verdicts.py` and `lowered.py` established. + +| family | authored by | replayed by | +|---|---|---| +| analysis-level violations (new) | `python tests/test_obligation_fact_parity.py --write` → `tests/fixtures/obligation_fact_parity.json` | `cargo test -p own-analysis --test obligation_parity` | +| Layer 3 verdicts | `python tests/test_verdict_fixtures.py --write` | `cargo test -p own-bridge --test verdicts` | +| rendered surfaces | `python tests/test_verdict_render_fixtures.py --write` | `cargo test -p own-bridge --test renders` | +| reproduction artifacts | `python tests/test_repro_fixtures.py --write` **and** `OWN_SHADOW_WRITE=1 cargo test -p own-shadow --test engine` (each engine writes only its own entry) | `cargo test -p own-shadow` | + +Steady state runs **zero Python**: `cd rust && cargo test --workspace`. + +### 6.3 Production dependency changes + +One: `own-analysis → own-ir`, an edge the DAG test already allowed, made +explicit rather than borrowed through `own-cfg`'s re-export. No new crate, and +no core crate depends on the bridge — the constraint that keeps bridge +inference out of the solver is untouched. + +### 6.4 Behaviour changes in Python + +**None.** The reference is the oracle; every divergence was resolved by +changing the port. + +### 6.5 The differential over the measured set + +Python-only, Rust-only, changed, ordering-only and unexplained are **0** on +every axis, and that is asserted rather than tallied: the Layer 3 replay +compares every replayed case's full ordered verdict list on every `Finding` +member, collects every divergence without fail-fast, and fails if one exists; +the analysis-level replay does the same over every violation member and the +dead-rule list. A green `cargo test --workspace` is 0/0/0/0/0 by construction. +The measured set itself is the [census](../generated/p022-cp4-census.md) and +the [surface inventory](../generated/p022-cp5-inventory.md); the unmeasured +set is now the coordinate-domain controls and the OD-1 door controls, and +nothing else. + +### 6.6 What the campaigns found + +Both campaigns are recorded in full +([fragment](../generated/p022-cp4b-mutations.md); definitions and raw results +under `docs/evidence/p022-cp4b-{1,2}.json`). Each was run twice, because the +first run of each found real holes — which is the whole point of running one: + +**4b.1 (the analysis).** Three survivors, two of them inherited from the +reference's own suite: + +* *`allow` beats `barrier` was unobservable.* The canonical test protocol's + barrier arguments and allow arguments are **disjoint**, so no event can match + both and deleting the allow check changes nothing. `tests/test_obligations.py` + has the same blind spot. A protocol whose barrier matches every + `OnPropertyChanged` and whose allow names one argument is the only shape in + which the rule fires at all. +* *`exit_barriers: false` was only tested against the end-of-method leak*, which + `run()` guards separately — the guard on `return`/`throw` had no case. +* *the unknown-argument rule was masked by the allow list*: with the narrowing + inverted, an unknown argument matches the **allow** entry too, and allow wins. + +A fourth mutation survived correctly: the `if !self.silent` guard around a +loop's emitting pass is **provably redundant**, because `_emit` re-checks the +flag. It is an equivalent mutant, the reference carries the same redundant +guard, and the mutation now attacks `_emit`'s guard — where the two-phase +discipline is actually enforced. + +**4b.2 (the bridge).** Four findings: + +* *first-wins was rescued by dedup.* Two duplicate records naming the **same** + barrier produce two byte-identical findings, which BR-V7 collapses — so the + rule was invisible through its own control. The records now name different + barriers. +* *the malformed-method skip only proved the easy half*: the bad record was + last, so a port that stopped at the first bad entry behaved identically. +* *the non-list-block control was vacuous*: with no protocols there is nothing + to report either way. The rule is observable only through a **scoped** + protocol, where an empty method list makes the rule dead and a silenced + family says nothing. +* *the rendered surfaces reached two of the five codes*, so neither exit + wording nor the `, ` `CPython` puts between scope entries was in the compared + bytes. + +Both campaigns now read fully caught with no missed catchers, on a clean tree. + +### 6.7 Two things measured, not claimed + +1. **BR-V5's "a slice shorter than two steps is dropped" does not apply on the + protocol path.** `_protocol_findings` filters steps with `line < 1` and + stops there; it never drops a short slice. So a leak off the end carries a + **one-step** slice (its second step would repeat the first), and one whose + open has no line carries **none**. The port reproduces the reference + exactly, and the surface inventory grew the families to match rather than + the port being bent to the prose. Whether the spec sentence or the code is + wrong is a Python-first question 4b does not answer. +2. **The family's append position is unobservable end to end.** BR-V1 puts + protocol findings after effects and before OWN050, but BR-V8 sorts by + `(file, line, column, code)` and two findings from different families never + share a code — so the code component decides before insertion order can. + Recorded here rather than dressed up as a control, the same way cp5.1 + recorded that three dedup-key members became unobservable once `message` + joined the key. + +### 6.8 The one golden family that was regenerated + +`tests/fixtures/repro/protocol_isloaded_violation.{repro,trace}.json`. The +Rust engine's `verdicts` layer moves from `refused` to `produced` and now +carries the same finding the reference does — the artifact is a record of what +each engine could produce, so a promotion changes it by construction. Same +shape of change as #339's `partial` → `full`, and stated for the same reason. +The `own-shadow` **reducer** is untouched: its scope is still +`["lowered", "summaries"]` and it still records the verdict layer as refused in +every reduction, because crossing that line is #260's acceptance, not 4b's. + +Everything else regenerated is an insertion into a ledger whose records depend +only on themselves — `tests/fixtures/repro/digests.json` gained the new +synthetic documents with **zero** existing records rewritten, and the verdict +manifest gained its cases the same way. + +## 7. The wording 4b earns + +> Layer 3 parity over the measured set, protocol family included; unmeasured +> set: coordinate-domain controls (decision owed), OD-1 door controls. + +Not "#259 complete" — the coordinate-domain decision is still owed. Not +"shadow mode": the `own-shadow` reducer still refuses the verdict layer, which +is #260's boundary. Not "P-022 done". diff --git a/docs/notes/p022-bridge-verdict-checkpoint5.md b/docs/notes/p022-bridge-verdict-checkpoint5.md index a37cc2f7..8b144717 100644 --- a/docs/notes/p022-bridge-verdict-checkpoint5.md +++ b/docs/notes/p022-bridge-verdict-checkpoint5.md @@ -9,6 +9,13 @@ > [`p022-cp5-mutations.md`](../generated/p022-cp5-mutations.md); nothing is > typed here. +> **Read the protocol paragraphs below as history.** #259 checkpoint 4b +> ([note](p022-bridge-verdict-checkpoint4b.md)) ported the obligation +> analysis, wired it through the bridge and **promoted both protocol +> documents out of `rust_replay_excluded`**. Where this note says the OBL +> analysis is not ported, or that a protocol-bearing document is refused, +> it describes the state at the time it was written. + 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` diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index 30486cf2..a31fd749 100644 --- a/docs/proposals/P-022-rust-core-migration.md +++ b/docs/proposals/P-022-rust-core-migration.md @@ -59,9 +59,9 @@ was #258 alone, which is satisfied. Per the checkpoints #259 itself defines: | 1 — typed OwnIR validation | **complete — no known strict-door divergence** | Three censuses. The first froze 77 controls and read 0/0/0 — then review found seven divergences the ledger could not express, because the same author wrote the ledger and the port and one gap in reading BR-D1 produced a matching gap in each (`_svc()` always supplied `lifetime`, so no control could omit it). The second is derived from `load()` and `obligations.py` line by line: **193 controls**, opening a further **58** permissive documents and **9** category mismatches. Closing them was architectural — the strict door is a sequential validator over the raw document (`own-ir/src/strict.rs`) reproducing BR-D1's interleaving of shape and semantics *per section, in declaration order*; `serde` is the typed constructor, and a document it rejects after validation is reported as a hole in the validator and asserted against. The obligation **acceptance grammar** is ported (`own-ir/src/protocol.rs`); protocol *analysis* is not, and is not part of what the door accepts. The third census admitted the two families the second had measured and deliberately excluded — source coordinates beyond signed 64 bits, and nesting depth — once #326 closed them Python-first. That opened 7 permissive documents and 8 more category mismatches, and the classification defect underneath them was the ledger reading its category off the reference's *diagnostic* rather than off the mechanism: `_check_column` raises one message for a bool, a string, a float, an out-of-range integer and a zero alike, so a bool column was filed as a 1-based-contract violation. Taxonomy is **seven** categories on **two axes** — `Shape` is now "no representable primitive or container form", `Location` is "a representable coordinate violating its domain rule", and `WellFormedness` covers records that are typed and vocabulary-legal and still cannot mean anything. **216 controls, matrix 35/181, 0/0/0**, no control escaping into serde; 48 mutations across the three rounds, all caught. #294 OD-2 remains a separate tolerant-door concern | | 2 — fact lowering | **complete** | `lower()` → `own_lowered`; **27/27** `rust_replay` cases in `tests/fixtures/lowered/manifest.json` byte-exact | | 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 | **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" | +| 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 was not ported; the bridge *refused* them rather than return a list with a family missing — **promoted at row 4b**), 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) | **complete — Layer 3 parity over the measured set, protocol family included** | The gap in #259's checkpoint list is closed: `ownlang/obligations.py` has a port. The analysis is `own-analysis/src/obligation.rs` (BR-B1: the analysis owns its verdict) — the `{OPEN, CLOSED}` set lattice with min-line provenance, the opens-before-closes-before-barriers leaf order with allow beating barrier, the never-invent asymmetry of an opaque write, the exits with the OWN001 anchor-at-acquire precedent, the loop's silent fixpoint and single emitting pass, the reachability-blind close-line evidence and the four-part sort key. The typed `Protocol`/`MethodEvents` values are built by **one** implementation of the grammar with two consumers — `own-ir/src/protocol.rs` grew from validate-only to validate-and-construct, so the strict door and the analysis cannot drift into two readings of one grammar — and no strict-door error text or category moved (the cp1 ledger's protocol controls stayed green). A third **fact-parity family** at the analysis level mirrors the DI/effect one (`tests/test_obligation_fact_parity.py` → `tests/fixtures/obligation_fact_parity.json` → `own-analysis/tests/obligation_parity.rs`): the reference's own `check_protocols` / `unmatched_scopes` freeze every violation WHOLE, and the replay compares every member with zero Python. Cases carry RAW documents, so each side builds the values with its own half of the grammar. The bridge maps BR-P3 in its BR-V1 place (after effects, before OWN050): the `(kind, definite)` table, the four deliberately line-free wordings, the identity derivations, the anchorless advisory OBL005 and the opened→barrier(→late-close) slice. `refuse_protocols` is **gone** and both reference documents are **promoted** out of `rust_replay_excluded` — the exclusion ledger shrinks by two and its pinned set with it, which is the contract change this checkpoint exists to make. Neither golden was regenerated to reach it. Eight synthetic controls (seven Layer 3, one rendered) close every row the corpus could not reach, insertion-stable. Two things are **measured and recorded rather than claimed**: the BR-V5 "a slice shorter than two steps is dropped" rule is not applied on the protocol path (a leak off the end carries a one-step slice), and the family's append POSITION is unobservable end to end because the sort key's code component decides first. One golden family IS regenerated, for a stated reason: the shadow artifact and trace of the protocol document, whose Rust `verdicts` layer moves from `refused` to `produced` — the same shape of change #339 recorded for `partial` → `full`; the `own-shadow` reducer still refuses Layer 3, which is #260's boundary and unmoved. Counts live only in the generated fragments [`p022-cp4-census.md`](../generated/p022-cp4-census.md), [`p022-cp5-inventory.md`](../generated/p022-cp5-inventory.md) and [`p022-cp4b-mutations.md`](../generated/p022-cp4b-mutations.md) ([note](../notes/p022-bridge-verdict-checkpoint4b.md)) | +| 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. Row **4b** has since landed (the protocol family is ported and replayed), so what remains for #259's final acceptance is the coordinate-domain decision alone. 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:** @@ -72,14 +72,14 @@ was #258 alone, which is satisfied. Per the checkpoints #259 itself defines: | 5c | `own-codegen` (analysis-independent sibling) | #257 | **ready**, independent of the analysis path — parallelizable | | 6a | OwnIR **bridge semantics formalized** before the port | #258 | **complete** — see above | | 6b | Rust `own-bridge`, layered OwnIR parity | #259 | **in progress** — checkpoint table above | -| 7a | dual-engine shadow mode + zero-diff reproduction artifacts | #260 (supported by #269) | **infrastructure COMPLETE — checkpoints 1–4 landed**; final acceptance **still blocked by #259**. Every item this row listed as *sliceable now* is built and replayed with zero of the other engine's runtime: cp1 same-input `OwnIR` capture + canonical hash and the reproduction-artifact format; cp2 the **engine protocol** (each engine declares what it could *produce*); cp3 the **`AnalysisTrace`** (#269 — stable-ID normalization, per-layer ordering semantics *declared* rather than normalized away); cp4 **first-divergence reduction** over the *lowered*/MOS layers, naming layer, step and the minimal difference. `ownlang/repro.py` is the reference emitter, `own-shadow` the port's half. **80 documents** digest-pinned and re-hashed **0/0/0**, **9** artifacts + **9** traces + **9** reductions reproduced byte-for-byte, **80** tamper controls, **6** executable domain refusals, **18** structural + **5** backstop controls per side, campaigns **30/30**, **11/11**, **11/11**, **11/11** caught. Divergence classification, now **computed** by the reducer rather than gate-implied: left-only 0 / right-only 0 / changed 0 / ordering-only 0 / unexplained 0, with 2 `status` observations, both boundaries the port declares ([cp1](../notes/p022-shadow-infra-checkpoint1.md), [cp2](../notes/p022-shadow-infra-checkpoint2.md), [cp3](../notes/p022-shadow-infra-checkpoint3.md), [cp4](../notes/p022-shadow-infra-checkpoint4.md), [generated census](../generated/p022-shadow-census.md)). Four **findings**, each recorded and resolved as a contract decision rather than by bending either engine: `-0` reads as an integer in CPython's `json` and a float in `serde_json`; the trace schema was ambiguous about where a duplicate address takes its `~n`; the capture carried the MOS document in a key order its own surface does not fix; and the two reducers disagreed about whether object key order is significant. **Still NOT shadow mode and not parity**: the reducer *refuses* the verdict layer and records the refusal, because acceptance compares end diagnostics and needs #259 cp5 + 4b. Crossing that line is a contract decision — the scope, engine and layer vocabularies are frozen constants with tests that fail when they move. **Also not proved: #260's raw-byte same-input invariant.** What cp1 establishes is shared *canonical document identity*; canonical-equivalent input is not byte-identical input, so acceptance must additionally prove that both engines consumed the identical captured byte sequence. Three departures from the slice's brief — capture and the artifact format kept as one checkpoint, the `-0` domain narrowing (an engine divergence the brief said to report rather than decide), and `sha2` for `own-shadow` only — are ratified on the record in the [owner-decision ledger](../notes/p022-shadow-infra-owner-decisions.md) | +| 7a | dual-engine shadow mode + zero-diff reproduction artifacts | #260 (supported by #269) | **infrastructure COMPLETE — checkpoints 1–4 landed**; final acceptance **still blocked by #259**. Every item this row listed as *sliceable now* is built and replayed with zero of the other engine's runtime: cp1 same-input `OwnIR` capture + canonical hash and the reproduction-artifact format; cp2 the **engine protocol** (each engine declares what it could *produce*); cp3 the **`AnalysisTrace`** (#269 — stable-ID normalization, per-layer ordering semantics *declared* rather than normalized away); cp4 **first-divergence reduction** over the *lowered*/MOS layers, naming layer, step and the minimal difference. `ownlang/repro.py` is the reference emitter, `own-shadow` the port's half. Every document digest-pinned and re-hashed, every artifact, trace and reduction reproduced byte-for-byte, with tamper, domain-refusal, structural and backstop controls on both sides and four fully-caught campaigns — all of it counted in the generated census, never here (row 4b removed one of its `status` observations by wiring the family the port used to refuse, which is exactly why this row states no number). Divergence classification, now **computed** by the reducer rather than gate-implied: left-only 0 / right-only 0 / changed 0 / ordering-only 0 / unexplained 0, with the remaining `status` observations boundaries the port declares ([cp1](../notes/p022-shadow-infra-checkpoint1.md), [cp2](../notes/p022-shadow-infra-checkpoint2.md), [cp3](../notes/p022-shadow-infra-checkpoint3.md), [cp4](../notes/p022-shadow-infra-checkpoint4.md), [generated census](../generated/p022-shadow-census.md)). Four **findings**, each recorded and resolved as a contract decision rather than by bending either engine: `-0` reads as an integer in CPython's `json` and a float in `serde_json`; the trace schema was ambiguous about where a duplicate address takes its `~n`; the capture carried the MOS document in a key order its own surface does not fix; and the two reducers disagreed about whether object key order is significant. **Still NOT shadow mode and not parity**: the reducer *refuses* the verdict layer and records the refusal, because acceptance compares end diagnostics; cp5 and 4b have landed, so what that line now waits on is #259's own final acceptance. Crossing that line is a contract decision — the scope, engine and layer vocabularies are frozen constants with tests that fail when they move. **Also not proved: #260's raw-byte same-input invariant.** What cp1 establishes is shared *canonical document identity*; canonical-equivalent input is not byte-identical input, so acceptance must additionally prove that both engines consumed the identical captured byte sequence. Three departures from the slice's brief — capture and the artifact format kept as one checkpoint, the `-0` domain narrowing (an engine divergence the brief said to report rather than decide), and `sha2` for `own-shadow` only — are ratified on the record in the [owner-decision ledger](../notes/p022-shadow-infra-owner-decisions.md) | | 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:** 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 +**Preferred queue:** the coordinate-domain decision → #259 final acceptance → +#260 acceptance / #261. Both cp5 and 4b are off this queue: each is complete at +its surface, and the one thing #259's final acceptance still needs is 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 diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 0c505738..f3d7453d 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, 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-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 with no known divergence — the first 0/0/0 proved to be the ledger agreeing with its own author, and the second omitted two families that a Python-first defensive-limit change (#326) had to close before the third could measure them; analysis wiring complete at the checkpoint-4 surface — `check_facts` through the real analyses, Layer 3 goldens built, with an executable exclusion ledger naming each declared boundary; **cp5 complete at its surface** — the replay compares EVERY `Finding` member (the BR-V4 wording matrix and the BR-V5 evidence slices included) and every refusal in full, and a second fixture family freezes the BR-V9 rendered surfaces byte for byte, all against goldens none of which was regenerated; **row 4b complete** — the obligation-protocol analysis (OBL001–005) is ported into `own-analysis`, its typed values come from the ONE grammar in `own-ir` that the strict door already delegated to, an analysis-level fact-parity family freezes every violation member with zero Python, the bridge maps BR-P3 in its BR-V1 place, and both protocol documents are promoted out of the exclusion ledger without regenerating either golden; what remains for #259's final acceptance is the coordinate-domain decision. Every count is generated: `docs/generated/p022-cp4-census.md`, `docs/generated/p022-cp5-inventory.md` and `docs/generated/p022-cp4b-mutations.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/pyproject.toml b/pyproject.toml index 2423aafb..e13bc4ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ files = [ "tests/verdict_census.py", "tests/verdict_render_census.py", "tests/verdict_surface_inventory.py", + "tests/test_obligation_fact_parity.py", "scripts/mutate_campaign.py", "scripts/render_checkpoint_status.py", ] diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 78a9eca1..b251e97c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -80,6 +80,7 @@ version = "0.1.0" dependencies = [ "own-cfg", "own-diagnostics", + "own-ir", "own-syntax", "serde_json", ] diff --git a/rust/crates/own-analysis/Cargo.toml b/rust/crates/own-analysis/Cargo.toml index 276494c0..fe64ec89 100644 --- a/rust/crates/own-analysis/Cargo.toml +++ b/rust/crates/own-analysis/Cargo.toml @@ -16,6 +16,12 @@ version = "0.1.0" [dependencies] own-cfg = { path = "../own-cfg" } own-diagnostics = { path = "../own-diagnostics" } +# The obligation analysis (#259 cp4b) reads the typed protocol grammar, which +# lives in own-ir because the strict door delegates to it (spec/Bridge.md BR-D1) +# — one implementation of the grammar, two consumers. An already-allowed DAG +# edge (own-diagnostics/tests/dag.rs), made explicit rather than borrowed +# through own-cfg's re-export. +own-ir = { path = "../own-ir" } [dev-dependencies] # own-syntax is a TEST-only edge (parity/metamorphic tests call `parse`); the diff --git a/rust/crates/own-analysis/src/lib.rs b/rust/crates/own-analysis/src/lib.rs index 499c6b73..3685beb5 100644 --- a/rust/crates/own-analysis/src/lib.rs +++ b/rust/crates/own-analysis/src/lib.rs @@ -16,6 +16,7 @@ pub mod check; pub mod di; pub mod effect; pub mod lifetime; +pub mod obligation; pub mod ownership; pub mod solver; @@ -23,5 +24,6 @@ pub use check::check_module; pub use di::{check_di, di_verdicts}; pub use effect::{effect_diagnostics, effect_verdicts, find_effect_storms}; pub use lifetime::check_lifetimes; +pub use obligation::{check_protocols, unmatched_scopes, Violation, ViolationKind}; pub use ownership::analyze; pub use solver::{solve, solve_with, Analysis, ControlFlowGraph, Lattice, Schedule, Solution}; diff --git a/rust/crates/own-analysis/src/obligation.rs b/rust/crates/own-analysis/src/obligation.rs new file mode 100644 index 00000000..2b1b2ade --- /dev/null +++ b/rust/crates/own-analysis/src/obligation.rs @@ -0,0 +1,584 @@ +//! Obligation-protocol analysis (OBL001–005) — an exact port of the checker in +//! `ownlang/obligations.py`. +//! +//! A legacy method often breaks one of its *own* invariants on purpose, +//! briefly: `IsLoaded = false` while a document tree is rebuilt, +//! `_suppressNotifications = true` around a batch update, `BeginUpdate()` +//! before a bulk edit. The invariant is allowed to be false — *locally*. The +//! bug is publishing that broken state to the outside world: raising +//! `PropertyChanged("Document")`, returning, or throwing while the flag is +//! still down. No general-purpose checker knows that `IsLoaded` means "the +//! document is consistent"; the project does, and declares it as an +//! **obligation protocol**. +//! +//! Like [`crate::di`] over the DI registration graph and [`crate::effect`] over +//! the render-scope binding graph, this is a **fact-driven** analysis with no +//! `.own` surface: the `OwnIR` bridge feeds it [`Protocol`] rules and +//! [`MethodEvents`] trees, both built by the one grammar in +//! [`own_ir::protocol`], and this module owns the verdict (spec/Bridge.md +//! BR-B1). The codes, the messages and the evidence slices are the bridge's +//! (BR-P3) — a [`Violation`] carries the facts they are synthesized from and +//! not a word of prose. +//! +//! The walk is path-sensitive over the structured event tree: the obligation +//! state is a **set** over `{OPEN, CLOSED}` joined by union at merges, so the +//! definite/maybe split falls out of the lattice exactly as OWN002 vs OWN009 +//! does in the core. Loops are solved to a local fixpoint silently and their +//! bodies re-walked once on the converged header state, so a barrier inside a +//! loop reports once — the two-phase emission discipline of the core analyzer, +//! applied per loop. +//! +//! # The precision policy (the project's standing red line: never invent a +//! violation) +//! +//! * an **opaque** write to a tracked flag may *discharge* an obligation but +//! never *creates* one: if OPEN is possible the state gains CLOSED (the write +//! may have closed it), and a closed state stays closed; +//! * a call the protocol does not name is **neutral** — it neither discharges +//! nor crosses. A callee that flips the flag internally is invisible in v1 +//! (interprocedural obligation summaries are the P-025 phase-3 slice); +//! * a call with an **unknown argument** does not match an args-narrowed +//! barrier ([`own_ir::protocol::Matcher::matches`]); +//! * protocols are explicitly scoped — a rule only ever fires where the project +//! asked for it. + +use own_ir::protocol::{Event, MethodEvents, Protocol}; + +/// Which kind of crossing a [`Violation`] reports. +/// +/// The bridge's `(kind, definite)` table turns this into OBL001–OBL004; the +/// split also decides whether the late-close evidence hop applies, because an +/// exit leak has no barrier to be late for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ViolationKind { + /// A configured barrier fired while the obligation was open. + Barrier, + /// The method left — `return`, `throw`, or off the end — while it was open. + Exit, +} + +impl ViolationKind { + /// The reference's `kind` string, and the fact-parity fixture's spelling. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Barrier => "barrier", + Self::Exit => "exit", + } + } +} + +/// One obligation-protocol violation, ready for the bridge to phrase. +/// +/// `line` anchors where the violation manifests: the barrier site for a +/// `barrier`/`return`/`throw` crossing, the **open** site for an obligation +/// leaking off the end of the method (the OWN001 precedent: a leak anchors at +/// the acquire). `definite` is the lattice split — `true` when the obligation +/// is open on *every* path reaching the point, `false` when only on some path. +/// `close_line` is the earliest close site after `line`, if one exists: the +/// "closed only here, after the barrier" evidence hop. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Violation { + pub protocol: String, + pub method: String, + pub file: String, + pub line: i64, + pub kind: ViolationKind, + pub definite: bool, + pub open_line: i64, + /// `OnPropertyChanged(Document)` | `return` | `throw` | `end of method`. + pub barrier_desc: String, + pub close_line: Option, +} + +/// The obligation state lattice: a set over `{OPEN, CLOSED}`, joined by union. +/// +/// A pair of flags rather than a set type, because the set has exactly two +/// possible members: `{}` is the reference's bottom, and `{OPEN}` — open on +/// every path — is what makes a crossing *definite*. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct States { + open: bool, + closed: bool, +} + +impl States { + const BOTTOM: Self = Self { + open: false, + closed: false, + }; + const OPEN: Self = Self { + open: true, + closed: false, + }; + const CLOSED: Self = Self { + open: false, + closed: true, + }; + + const fn union(self, other: Self) -> Self { + Self { + open: self.open || other.open, + closed: self.closed || other.closed, + } + } +} + +/// A path state: which obligation states are possible, plus the earliest open +/// site among the paths where it is open (evidence provenance, min-line joined +/// exactly like the core's `_join_sites`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct State { + states: States, + open_line: Option, +} + +impl State { + const BOTTOM: Self = Self { + states: States::BOTTOM, + open_line: None, + }; + + /// The state a method starts in: nothing is owed yet. + const fn start() -> Self { + Self { + states: States::CLOSED, + open_line: None, + } + } + + fn join(self, other: Self) -> Self { + Self { + states: self.states.union(other.states), + open_line: match (self.open_line, other.open_line) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }, + } + } +} + +/// Path-sensitive walk of one method's event tree against one protocol. +/// +/// Sequences and branches are walked exactly once (the emitting pass); a loop +/// body is iterated to a fixpoint with emission off, then re-walked once on the +/// converged header state. +struct Walker<'a> { + proto: &'a Protocol, + method: &'a MethodEvents, + silent: bool, + violations: Vec, +} + +impl<'a> Walker<'a> { + const fn new(proto: &'a Protocol, method: &'a MethodEvents) -> Self { + Self { + proto, + method, + silent: false, + violations: Vec::new(), + } + } + + fn emit(&mut self, kind: ViolationKind, line: i64, st: State, desc: String) { + if self.silent { + return; + } + self.violations.push(Violation { + protocol: self.proto.name.clone(), + method: self.method.name.clone(), + file: self.method.file.clone(), + line, + kind, + // `states == {OPEN}`: open on every path, not merely on one. + definite: st.states == States::OPEN, + open_line: st.open_line.unwrap_or(line), + barrier_desc: desc, + close_line: None, + }); + } + + /// One leaf event (`assign` / `call`). The order IS the semantics: opens + /// before closes before barriers. + fn leaf(&mut self, ev: &Event, st: State) -> State { + let p = self.proto; + if p.opens.matches(ev) { + // (re-)open: keep the earliest open site as provenance. + let line = ev.line(); + return State { + states: States::OPEN, + open_line: Some(st.open_line.map_or(line, |prev| prev.min(line))), + }; + } + if p.closes.matches(ev) { + return State { + states: States::CLOSED, + open_line: None, + }; + } + if !st.states.open { + return st; + } + // allow beats barrier: an explicitly safe event never crosses. + // The reference stops at the FIRST matching barrier; the description + // it emits is read off the event, never off the barrier, so "any + // barrier matched" is the same decision without a value to discard. + if !p.allow.iter().any(|a| a.matches(ev)) && p.barriers.iter().any(|b| b.matches(ev)) { + let desc = match ev { + Event::Call { callee, arg, .. } => { + format!("{callee}({})", arg.as_deref().unwrap_or("")) + } + Event::Assign { target, .. } => format!("{target} = ..."), + // `leaf` is only ever reached with a leaf event. + _ => String::new(), + }; + self.emit(ViolationKind::Barrier, ev.line(), st, desc); + } + // opaque write to a tracked flag: may discharge, never opens (the + // never-invent asymmetry — see the module docs). + if let Event::Assign { + target, + value: None, + .. + } = ev + { + if p.tracks_target(target) { + return State { + states: st.states.union(States::CLOSED), + open_line: st.open_line, + }; + } + } + st + } + + fn exit(&mut self, line: i64, st: State, desc: &str) { + if self.proto.exit_barriers && st.states.open { + self.emit(ViolationKind::Exit, line, st, desc.to_owned()); + } + } + + /// Returns `(state, alive)`: `alive` is `false` when every path through the + /// sequence has already left the method. + fn walk_seq(&mut self, events: &[Event], st: State) -> (State, bool) { + let mut st = st; + for ev in events { + let (next, alive) = self.walk(ev, st); + st = next; + if !alive { + return (st, false); + } + } + (st, true) + } + + fn walk(&mut self, ev: &Event, st: State) -> (State, bool) { + match ev { + Event::Assign { .. } | Event::Call { .. } => (self.leaf(ev, st), true), + Event::Return { line } => { + self.exit(*line, st, "return"); + (State::BOTTOM, false) + } + Event::Throw { line } => { + self.exit(*line, st, "throw"); + (State::BOTTOM, false) + } + Event::If { then, orelse, .. } => { + let (s1, a1) = self.walk_seq(then, st); + let (s2, a2) = self.walk_seq(orelse, st); + if !a1 && !a2 { + return (State::BOTTOM, false); + } + let left = if a1 { s1 } else { State::BOTTOM }; + let right = if a2 { s2 } else { State::BOTTOM }; + (left.join(right), true) + } + Event::While { body, .. } => { + // Local fixpoint on the header state, silently. It terminates + // for the reason the reference's does: the state set only grows + // under union and the provenance line only falls, and both + // domains are finite (the lines are the tree's own). + let mut header = st; + let was_silent = self.silent; + self.silent = true; + loop { + let (out, body_alive) = self.walk_seq(body, header); + let next = header.join(if body_alive { out } else { State::BOTTOM }); + if next == header { + break; + } + header = next; + } + self.silent = was_silent; + // One emitting pass over the body on the converged header state + // (skipped while an enclosing loop is still in its silent + // phase, which is what keeps a nested loop to one report). + if !self.silent { + self.walk_seq(body, header); + } + // Zero iterations are always possible: the exit state is the + // header. + (header, true) + } + } + } + + fn run(mut self) -> Vec { + let (st, alive) = self.walk_seq(&self.method.events, State::start()); + if alive && self.proto.exit_barriers && st.states.open { + // Anchor the leak at the open site (the OWN001 precedent); an + // unknown provenance anchors at 0. + let anchor = st.open_line.unwrap_or(0); + self.emit(ViolationKind::Exit, anchor, st, "end of method".to_owned()); + } + self.violations + } +} + +/// Every close-event line in the tree, **reachability ignored** — evidence for +/// the "closed only here, after the barrier" hop. +fn close_lines(proto: &Protocol, events: &[Event], out: &mut Vec) { + for ev in events { + match ev { + Event::Assign { .. } | Event::Call { .. } => { + if proto.closes.matches(ev) { + out.push(ev.line()); + } + } + Event::If { then, orelse, .. } => { + close_lines(proto, then, out); + close_lines(proto, orelse, out); + } + Event::While { body, .. } => close_lines(proto, body, out), + Event::Return { .. } | Event::Throw { .. } => {} + } + } +} + +/// Check every protocol against every method in its scope. Deterministic; +/// sorted by location. Port of `obligations.check_protocols`. +#[must_use] +pub fn check_protocols(protocols: &[Protocol], methods: &[MethodEvents]) -> Vec { + let mut out: Vec = Vec::new(); + for proto in protocols { + for method in methods { + if !proto.applies_to(&method.name) { + continue; + } + let violations = Walker::new(proto, method).run(); + if violations.is_empty() { + continue; + } + let mut closes = Vec::new(); + close_lines(proto, &method.events, &mut closes); + closes.sort_unstable(); + for mut v in violations { + // The late-close evidence hop only makes sense for a barrier + // crossing ("the close exists, but after the publish"); an exit + // leak has no barrier to be late for. + if v.kind == ViolationKind::Barrier { + v.close_line = closes.iter().find(|c| **c > v.line).copied(); + } + out.push(v); + } + } + } + // A STABLE sort, like the reference's: ties keep the protocol-then-method + // construction order. + out.sort_by(|a, b| { + a.file + .cmp(&b.file) + .then_with(|| a.line.cmp(&b.line)) + .then_with(|| a.protocol.cmp(&b.protocol)) + .then_with(|| a.barrier_desc.cmp(&b.barrier_desc)) + }); + out +} + +/// Protocols whose scope matched no reported method — a dead rule. +/// +/// Likely a typo'd scope. Surfaced as an advisory, never a verdict: a rule that +/// structurally never fires is decoration, and silently dead project rules are +/// worse than none. Port of `obligations.unmatched_scopes`. +#[must_use] +pub fn unmatched_scopes<'a>( + protocols: &'a [Protocol], + methods: &[MethodEvents], +) -> Vec<&'a Protocol> { + protocols + .iter() + .filter(|p| !p.methods.is_empty() && !methods.iter().any(|m| p.applies_to(&m.name))) + .collect() +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] +mod tests { + use super::{check_protocols, unmatched_scopes, Violation}; + use own_ir::protocol::{parse_method, parse_protocol, MethodEvents, Protocol}; + use serde_json::json; + + /// The canonical test protocol, in the document shape the grammar takes: + /// `IsLoaded = false` opens, `IsLoaded = true` closes, + /// `OnPropertyChanged(Document|Rows)` is a barrier, `OnPropertyChanged(IsBusy)` + /// is allowed. + fn doc_load() -> Protocol { + parse_protocol(&json!({ + "name": "DocLoad", + "opens": {"kind": "assign", "target": "IsLoaded", "value": false}, + "closes": {"kind": "assign", "target": "IsLoaded", "value": true}, + "barriers": [{"kind": "call", "callee": "OnPropertyChanged", + "args": ["Document", "Rows"]}], + "allow": [{"kind": "call", "callee": "OnPropertyChanged", + "args": ["IsBusy", "IsLoaded"]}] + })) + .unwrap() + } + + fn method(events: &serde_json::Value) -> MethodEvents { + parse_method(&json!({"name": "Ns.VM.Load", "file": "VM.cs", "events": events})).unwrap() + } + + fn codes(vs: &[Violation]) -> Vec<(&'static str, bool, i64)> { + vs.iter() + .map(|v| (v.kind.as_str(), v.definite, v.line)) + .collect() + } + + fn open_ev() -> serde_json::Value { + json!({"ev": "assign", "target": "IsLoaded", "value": false, "line": 10}) + } + fn close_ev() -> serde_json::Value { + json!({"ev": "assign", "target": "IsLoaded", "value": true, "line": 90}) + } + fn notify_doc() -> serde_json::Value { + json!({"ev": "call", "callee": "OnPropertyChanged", "arg": "Document", "line": 50}) + } + + /// The shape the whole family exists for: open → barrier → close is one + /// definite crossing, anchored at the barrier, with the open as provenance + /// and the late close as evidence. + #[test] + fn open_barrier_close_is_one_definite_crossing() { + let vs = check_protocols( + &[doc_load()], + &[method(&json!([open_ev(), notify_doc(), close_ev()]))], + ); + assert_eq!(codes(&vs), vec![("barrier", true, 50)]); + assert_eq!(vs[0].open_line, 10); + assert_eq!(vs[0].close_line, Some(90)); + assert_eq!(vs[0].barrier_desc, "OnPropertyChanged(Document)"); + assert_eq!(vs[0].file, "VM.cs"); + assert_eq!(vs[0].method, "Ns.VM.Load"); + } + + /// The fixed twin: a close before the barrier is silence. Without it the + /// test above proves only that the walker emits, not that it decides. + #[test] + fn close_before_the_barrier_is_clean() { + let vs = check_protocols( + &[doc_load()], + &[method(&json!([open_ev(), close_ev(), notify_doc()]))], + ); + assert!(vs.is_empty(), "{vs:?}"); + } + + /// The two-phase loop emission: the fixpoint iterations are silent, so a + /// barrier inside a loop body reports exactly once — nested loops included. + #[test] + fn a_barrier_in_a_loop_reports_exactly_once() { + let vs = check_protocols( + &[doc_load()], + &[method(&json!([ + open_ev(), + {"ev": "while", "line": 20, "body": [notify_doc()]}, + close_ev() + ]))], + ); + assert_eq!(codes(&vs), vec![("barrier", true, 50)]); + let nested = check_protocols( + &[doc_load()], + &[method(&json!([ + open_ev(), + {"ev": "while", "line": 20, "body": [ + {"ev": "while", "line": 21, "body": [notify_doc()]}]}, + close_ev() + ]))], + ); + assert_eq!(codes(&nested), vec![("barrier", true, 50)]); + } + + /// The never-invent asymmetry, both halves: an opaque write to a tracked + /// flag downgrades an open obligation to a maybe, and never opens one. + #[test] + fn an_opaque_write_may_discharge_but_never_opens() { + let downgraded = check_protocols( + &[doc_load()], + &[method(&json!([ + open_ev(), + {"ev": "assign", "target": "IsLoaded", "line": 20}, + notify_doc(), + close_ev() + ]))], + ); + assert_eq!(codes(&downgraded), vec![("barrier", false, 50)]); + let invented = check_protocols( + &[doc_load()], + &[method(&json!([ + {"ev": "assign", "target": "IsLoaded", "line": 5}, + notify_doc() + ]))], + ); + assert!(invented.is_empty(), "{invented:?}"); + } + + /// An exit leak anchors at the OPEN site and carries no late-close hop, + /// even when a close exists later in the tree. + #[test] + fn an_end_of_method_leak_anchors_at_the_open() { + let vs = check_protocols(&[doc_load()], &[method(&json!([open_ev()]))]); + assert_eq!(codes(&vs), vec![("exit", true, 10)]); + assert_eq!(vs[0].barrier_desc, "end of method"); + assert_eq!(vs[0].close_line, None); + + let thrown = check_protocols( + &[doc_load()], + &[method(&json!([ + open_ev(), + {"ev": "if", "line": 20, "then": [{"ev": "throw", "line": 25}], "else": []}, + close_ev() + ]))], + ); + assert_eq!(codes(&thrown), vec![("exit", true, 25)]); + assert_eq!( + thrown[0].close_line, None, + "an exit leak has no barrier to be late for" + ); + } + + /// A scope matching nothing is a dead rule; an unscoped protocol never is. + #[test] + fn a_dead_scope_is_reported_and_an_unscoped_protocol_is_not() { + let scoped = parse_protocol(&json!({ + "name": "Ghost", + "opens": {"kind": "assign", "target": "x", "value": false}, + "closes": {"kind": "assign", "target": "x", "value": true}, + "scope": {"methods": ["VM.Misspelled"]} + })) + .unwrap(); + let protocols = [scoped]; + let methods = [method(&json!([]))]; + let dead = unmatched_scopes(&protocols, &methods); + assert_eq!( + dead.iter().map(|p| p.name.as_str()).collect::>(), + vec!["Ghost"] + ); + let unscoped = [doc_load()]; + assert!(unmatched_scopes(&unscoped, &[]).is_empty()); + } +} diff --git a/rust/crates/own-analysis/tests/obligation_parity.rs b/rust/crates/own-analysis/tests/obligation_parity.rs new file mode 100644 index 00000000..ffc03496 --- /dev/null +++ b/rust/crates/own-analysis/tests/obligation_parity.rs @@ -0,0 +1,258 @@ +//! Fact-level differential parity for the obligation-protocol analysis +//! (OBL001–005) — the Rust side of the Python-authored oracle +//! (`tests/fixtures/obligation_fact_parity.json`, regenerate: +//! `python tests/test_obligation_fact_parity.py --write`), #259 checkpoint 4b. +//! +//! ```text +//! raw protocols[] / protocol_functions[] documents +//! → own_ir::protocol::{parse_protocol, parse_method} +//! → own_analysis::{check_protocols, unmatched_scopes} +//! ≡ the frozen violation list and dead-rule list, member for member +//! ``` +//! +//! The family has no `.own` surface and Python is the reference, exactly as for +//! DI and effects (`fact_parity.rs`). Two things make it a real differential +//! rather than a re-assertion: +//! +//! * the cases carry the **raw documents**, so this side builds the typed +//! values with its own half of the shared grammar. A grammar that accepted +//! the same records and built a different value would show up here as a +//! verdict divergence rather than passing unnoticed; +//! * every member of a violation is compared — `line`, `definite`, `open_line`, +//! `barrier_desc` and `close_line` included — in the reference's order, so a +//! port that got the anchor right and the provenance wrong is red. +//! +//! Codes, messages and evidence slices are deliberately absent: those are the +//! bridge's (BR-P3) and the Layer 3 verdict family compares them. +//! +//! Divergences are collected without fail-fast (P-022 discipline rule 3) and +//! reported together, each naming its case. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use own_analysis::{check_protocols, unmatched_scopes}; +use own_ir::protocol::{parse_method, parse_protocol, MethodEvents, Protocol}; +use serde_json::Value; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/obligation_fact_parity.json" +); + +/// The surface version the fixture and this replay agree on. A bump is a +/// reviewed contract change, not a passing reshape. +const PARITY_VERSION: u64 = 1; + +/// One violation as the fixture spells it — the comparison key, in the +/// reference dataclass's member order. +#[derive(Debug, PartialEq, Eq)] +struct Row { + protocol: String, + method: String, + file: String, + line: i64, + kind: String, + definite: bool, + open_line: i64, + barrier_desc: String, + close_line: Option, +} + +fn load() -> Value { + let raw = std::fs::read_to_string(FIXTURE).expect( + "fixture missing — regenerate: python tests/test_obligation_fact_parity.py --write", + ); + let root: Value = serde_json::from_str(&raw).expect("obligation_fact_parity.json parses"); + assert_eq!( + root.get("obligation_parity_version") + .and_then(Value::as_u64), + Some(PARITY_VERSION), + "the fixture's surface version changed — teach this replay the new version" + ); + root +} + +/// The golden rows of one case, read strictly: a missing or mistyped member is +/// a fixture the replay does not understand, not a member to skip. +fn golden_rows(case: &Value) -> Vec { + case.get("expected") + .and_then(Value::as_array) + .expect("'expected'") + .iter() + .map(|v| Row { + protocol: v + .get("protocol") + .and_then(Value::as_str) + .expect("protocol") + .to_owned(), + method: v + .get("method") + .and_then(Value::as_str) + .expect("method") + .to_owned(), + file: v + .get("file") + .and_then(Value::as_str) + .expect("file") + .to_owned(), + line: v.get("line").and_then(Value::as_i64).expect("line"), + kind: v + .get("kind") + .and_then(Value::as_str) + .expect("kind") + .to_owned(), + definite: v + .get("definite") + .and_then(Value::as_bool) + .expect("definite"), + open_line: v + .get("open_line") + .and_then(Value::as_i64) + .expect("open_line"), + barrier_desc: v + .get("barrier_desc") + .and_then(Value::as_str) + .expect("barrier_desc") + .to_owned(), + // `null` is the reference's "no late close"; a missing key is a + // malformed golden and is not read as one. + close_line: match v.get("close_line") { + Some(Value::Null) => None, + Some(other) => Some(other.as_i64().expect("close_line is an integer")), + None => panic!("golden row carries no 'close_line'"), + }, + }) + .collect() +} + +fn documents(case: &Value, key: &str) -> Vec { + case.get(key) + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("case carries '{key}'")) + .clone() +} + +fn strings(case: &Value, key: &str) -> Vec { + case.get(key) + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("case carries '{key}'")) + .iter() + .map(|v| v.as_str().expect("a name").to_owned()) + .collect() +} + +#[test] +fn obligation_fact_parity() { + let root = load(); + let cases = root + .get("cases") + .and_then(Value::as_array) + .expect("'cases'"); + let mut failures: Vec = Vec::new(); + let mut violations = 0_usize; + let mut dead_rules = 0_usize; + + for case in cases { + let name = case.get("name").and_then(Value::as_str).expect("name"); + // The grammar is the port's, not the fixture's: a record the reference + // accepted must construct here too, or the two doors disagree. + let protocols: Vec = documents(case, "protocols") + .iter() + .map(|p| { + parse_protocol(p).unwrap_or_else(|e| { + panic!("{name}: the port refuses a protocol Python accepted: {e}") + }) + }) + .collect(); + let methods: Vec = documents(case, "methods") + .iter() + .map(|m| { + parse_method(m).unwrap_or_else(|e| { + panic!("{name}: the port refuses a method Python accepted: {e}") + }) + }) + .collect(); + + let got: Vec = check_protocols(&protocols, &methods) + .into_iter() + .map(|v| Row { + protocol: v.protocol, + method: v.method, + file: v.file, + line: v.line, + kind: v.kind.as_str().to_owned(), + definite: v.definite, + open_line: v.open_line, + barrier_desc: v.barrier_desc, + close_line: v.close_line, + }) + .collect(); + let want = golden_rows(case); + if got == want { + violations = violations.checked_add(want.len()).expect("count fits"); + } else { + failures.push(format!( + "obligation case {name}:\n python={want:#?}\n rust ={got:#?}" + )); + } + + let got_dead: Vec = unmatched_scopes(&protocols, &methods) + .into_iter() + .map(|p| p.name.clone()) + .collect(); + let want_dead = strings(case, "dead"); + if got_dead == want_dead { + dead_rules = dead_rules.checked_add(want_dead.len()).expect("count fits"); + } else { + failures.push(format!( + "obligation case {name} dead rules:\n python={want_dead:?}\n \ + rust ={got_dead:?}" + )); + } + } + + assert!( + failures.is_empty(), + "{} obligation divergence(s):\n{}", + failures.len(), + failures.join("\n") + ); + eprintln!( + "obligation fact parity: {} cases replayed ({violations} violations, \ + {dead_rules} dead rules)", + cases.len() + ); + assert!( + cases.len() >= 45, + "expected the full obligation corpus, got {}", + cases.len() + ); +} + +/// The walk must be a pure function of its inputs: the same documents twice +/// give the same ordered list. A `HashMap` iteration order leaking into the +/// sort would pass a single run and fail this one. +#[test] +fn the_walk_is_deterministic() { + let root = load(); + for case in root + .get("cases") + .and_then(Value::as_array) + .expect("'cases'") + { + let name = case.get("name").and_then(Value::as_str).expect("name"); + let protocols: Vec = documents(case, "protocols") + .iter() + .map(|p| parse_protocol(p).expect("a protocol the reference accepted")) + .collect(); + let methods: Vec = documents(case, "methods") + .iter() + .map(|m| parse_method(m).expect("a method the reference accepted")) + .collect(); + assert_eq!( + check_protocols(&protocols, &methods), + check_protocols(&protocols, &methods), + "{name}: check_protocols is not deterministic" + ); + } +} diff --git a/rust/crates/own-bridge/src/verdict.rs b/rust/crates/own-bridge/src/verdict.rs index 21138979..c7f59f5c 100644 --- a/rust/crates/own-bridge/src/verdict.rs +++ b/rust/crates/own-bridge/src/verdict.rs @@ -18,15 +18,19 @@ //! The pipeline is BR-V1 verbatim: lower → core `check_module` → map the //! ERROR-severity core diagnostics only, skipping the closed BR-V2 artifact //! list, each through its `subject` to a known handle (or refuse, BR-V3) → -//! append DI, effect, OWN050, OWN051 and OWN052 findings in that order → dedup -//! (BR-V7) → stable sort by `(file, line, column or 0, code)` (BR-V8). +//! append DI, effect, protocol, OWN050, OWN051 and OWN052 findings in that +//! order → dedup (BR-V7) → stable sort by `(file, line, column or 0, code)` +//! (BR-V8). //! -//! **Not wired, and refused rather than skipped:** the obligation-protocol -//! analysis (OBL001–005, `ownlang/obligations.py`) has no `own-analysis` port. -//! A document that declares a protocol would get a verdict list with a family -//! silently missing, so it is rejected with a [`BridgeError`] naming the -//! boundary; the verdict fixture ledger records the two reference documents -//! this excludes. +//! **The obligation-protocol family is wired (#259 checkpoint 4b).** Until 4b +//! this module *refused* a document declaring a protocol rather than return a +//! verdict list with a family silently missing, and the verdict ledger carried +//! the two reference documents as declared exclusions. `own-analysis` now +//! owns the walk (`obligation.rs`), `own-ir`'s grammar builds the values both +//! doors read, and BR-P3 below maps them: the `(kind, definite)` table, the +//! line-free wordings, the identity derivations and the opened→barrier(→late +//! close) slice. The two exclusions are promoted, and the goldens they were +//! excluded against were not regenerated to get there. //! //! **The dedup key is complete.** The reference deduplicates on //! `(file, line, column, code, component, event, handler, message, kind, @@ -51,7 +55,9 @@ use crate::lower::{self, as_col, Obj, Own051}; use crate::{ast, BridgeError}; use own_analysis::di::{self, Service, SiteTriple}; use own_analysis::effect::{self, Binding, Effect}; +use own_analysis::obligation::{Violation, ViolationKind}; use own_diagnostics::{Diagnostic, Severity}; +use own_ir::protocol::{self, MethodEvents, Protocol}; use own_ir::OwnIr; use serde_json::Value; use std::collections::{BTreeMap, HashSet}; @@ -959,19 +965,194 @@ fn dedup_key(f: &Finding) -> DedupKey { ) } -/// The obligation-protocol boundary: a document declaring a protocol cannot -/// be given a complete verdict list by this core, so it is refused. -fn refuse_protocols(root: &Obj) -> Result<(), BridgeError> { - match root.get("protocols") { - Some(Value::Array(items)) if !items.is_empty() => Err(BridgeError(format!( - "this document declares {} 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)", - items.len() - ))), - _ => Ok(()), +/// BR-P3: the obligation-protocol family. +/// +/// Rules and per-method event trees parse through the **shared** grammar +/// ([`own_ir::protocol`] — the single shape authority for both doors), +/// `own_analysis` owns the verdicts, and this function does what the bridge +/// owns: the `(kind, definite)` table, the wordings, the identity derivations +/// and the evidence slice. +/// +/// Three tolerances are the reference's, and two of them are visible only from +/// the raw document (BR-D2): a malformed entry is **skipped as a whole** rather +/// than patched into shape, a duplicate protocol name resolves **first-wins**, +/// and a `protocols` / `protocol_functions` block that is not a list yields +/// nothing. The last is unreachable through the typed `OwnIr` constructor +/// (#294 OD-1), so it is pinned by a `verdict::tests` control driving this +/// function on a raw object, exactly as the effect block's is. +fn protocol_findings(root: &Obj) -> Vec { + // Both blocks are checked for list-ness BEFORE either is parsed, as the + // reference does: a malformed sibling silences the whole family. + let (Some(raw_protos), Some(raw_fns)) = + (block(root, "protocols"), block(root, "protocol_functions")) + else { + return Vec::new(); + }; + let mut protocols: Vec = Vec::new(); + for raw in raw_protos { + let Ok(parsed) = protocol::parse_protocol(raw) else { + continue; + }; + // A duplicate name is rejected by the strict door; on this one the + // later record is skipped (first wins, deterministically) — the name + // is the identity the violation -> protocol re-pairing below needs. + if protocols.iter().any(|p| p.name == parsed.name) { + continue; + } + protocols.push(parsed); + } + let methods: Vec = raw_fns + .iter() + .filter_map(|raw| protocol::parse_method(raw).ok()) + .collect(); + if protocols.is_empty() { + return Vec::new(); + } + + let mut out = Vec::new(); + for v in own_analysis::check_protocols(&protocols, &methods) { + let proto = protocols + .iter() + .find(|p| p.name == v.protocol) + .expect("a violation names a protocol from the list it was checked against"); + let code = match (v.kind, v.definite) { + // The definite/maybe split is the same lattice story as OWN002 vs + // OWN009 — open on every path vs open on some path. + (ViolationKind::Barrier, true) => "OBL001", + (ViolationKind::Barrier, false) => "OBL002", + (ViolationKind::Exit, true) => "OBL003", + (ViolationKind::Exit, false) => "OBL004", + }; + let mut f = Finding::new(v.file.clone(), v.line, code, "protocol obligation"); + f.component = component_of(&v.method); + f.handler = String::from(v.method.rsplit('.').next().unwrap_or(&v.method)); + f.message = protocol_message(&v, proto); + // BR-V5: opened — barrier (or exit) — late close, with the steps + // whose line is unknown dropped. An end-of-method leak anchors AT the + // open, so its second step would repeat the first and is not built. + let mut flow: Vec<(i64, String)> = vec![( + v.open_line, + format!( + "obligation '{}' opens here ({})", + v.protocol, + proto.opens.describe() + ), + )]; + match v.kind { + ViolationKind::Barrier => flow.push(( + v.line, + format!("barrier '{}' fires while it is open", v.barrier_desc), + )), + ViolationKind::Exit if v.line != v.open_line => flow.push(( + v.line, + format!( + "the method exits here via {} while it is open", + v.barrier_desc + ), + )), + ViolationKind::Exit => {} + } + if let Some(close) = v.close_line { + flow.push(( + close, + "closed here — after the barrier has already fired".to_owned(), + )); + } + f.flow = flow + .into_iter() + .filter(|(line, _)| *line >= 1) + .map(|(line, label)| (v.file.clone(), line, label)) + .collect(); + f.event = v.protocol; + out.push(f); + } + // A scoped protocol that matched no reported method is a dead rule — + // surfaced honestly (advisory, never fails the build), like OWN050, and + // anchorless like OWN052 (BR-V5; the SARIF projection of that is OD-6). + for p in own_analysis::unmatched_scopes(&protocols, &methods) { + let mut f = Finding::new("?", 0, "OBL005", "protocol obligation"); + f.component = String::from("?"); + f.event.clone_from(&p.name); + f.advisory = true; + let mut scope: Vec<&str> = p.methods.iter().map(String::as_str).collect(); + scope.sort_unstable(); + f.message = format!( + "protocol '{}' is scoped to {} but no reported method matches — \ + the rule is dead (typo in scope.methods?)", + p.name, + py_list(&scope) + ); + out.push(f); + } + out +} + +/// `facts.get(key, [])` read as a list: absent is empty, a present non-list is +/// the reference's "not a list" refusal of the whole family. +fn block<'a>(root: &'a Obj, key: &str) -> Option<&'a [Value]> { + match root.get(key) { + None => Some(&[]), + Some(Value::Array(items)) => Some(items), + Some(_) => None, + } +} + +/// `v.method.rsplit(".", 2)[-2] if "." in name else name` — the second-to-last +/// dotted segment, or the whole name when it carries no dot. +/// +/// Reading the second element from the right is the same value the reference's +/// bounded split takes, including on the awkward inputs: `".Load"` and `"a..b"` +/// both name an empty component in both implementations. +fn component_of(method: &str) -> String { + String::from(method.rsplit('.').nth(1).unwrap_or(method)) +} + +/// `repr(sorted(methods))` — a Python list of strings, which `CPython` +/// renders with `, ` between elements and each element through `repr`. The +/// Layer 3 goldens compare it byte for byte, so the separator and the quote +/// rule are both contract. +fn py_list(items: &[&str]) -> String { + let rendered: Vec = items.iter().map(|s| py_repr(Some(s))).collect(); + format!("[{}]", rendered.join(", ")) +} + +/// BR-V4 for the OBL family. Deliberately **line-free**: `OwnAudit` fingerprints +/// findings on `(path, rule, message)`, so a line number here would break the +/// baseline ratchet and the FP-judge overlay on every unrelated edit. The lines +/// live in the evidence slice instead. +fn protocol_message(v: &Violation, proto: &Protocol) -> String { + let close = proto.closes.describe(); + match v.kind { + ViolationKind::Barrier => { + let state = if v.definite { + "is still open" + } else { + "may still be open (open on some path)" + }; + format!( + "obligation '{}' {state} when barrier '{}' fires in '{}' — \ + '{close}' must happen first", + v.protocol, v.barrier_desc, v.method + ) + } + ViolationKind::Exit => { + let state = if v.definite { + "is not closed" + } else { + "may not be closed (open on some path)" + }; + let exit_desc = if v.barrier_desc == "end of method" { + "the method falls off the end".to_owned() + } else { + format!("'{}' exits via {}", v.method, v.barrier_desc) + }; + format!( + "obligation '{}' {state} when {exit_desc} — the object is published \ + in its in-between state; close with '{close}' on every path (a finally \ + block covers the throw paths)", + v.protocol + ) + } } } @@ -981,8 +1162,6 @@ pub(crate) fn check_facts(facts: &OwnIr) -> Result, BridgeError> { let root = root_value .as_object() .expect("a struct serializes to an object"); - refuse_protocols(root)?; - let lowering = lower::lower_full(facts)?; let module = ast::to_module(&lowering.doc)?; let diags = own_analysis::check_module(&module); @@ -1003,7 +1182,7 @@ pub(crate) fn check_facts(facts: &OwnIr) -> Result, BridgeError> { 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(protocol_findings(root)); 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); @@ -1035,7 +1214,7 @@ pub(crate) fn check_facts(facts: &OwnIr) -> Result, BridgeError> { #[cfg(test)] #[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)] mod tests { - use super::{dedup, di_findings, effect_findings, map_core, Finding, Obj}; + use super::{dedup, di_findings, effect_findings, map_core, protocol_findings, Finding, Obj}; use own_diagnostics::{Diagnostic, Severity}; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -1339,6 +1518,82 @@ mod tests { assert!(effect_findings(&root).unwrap().is_empty()); } + /// BR-D2 on the raw document: a `protocols` or `protocol_functions` block + /// that is not a list silences the WHOLE family — including the sibling + /// block, which the reference checks before parsing either. + /// + /// Driven through `protocol_findings` directly because the typed `OwnIr` + /// constructor types both blocks as arrays, so no document can carry a + /// non-list one through the door (#294 OD-1). The same shape, and the same + /// reason, as `effects_block_that_is_not_a_list_yields_nothing` above. + #[test] + fn protocol_blocks_that_are_not_lists_yield_nothing() { + let rule = json!({ + "name": "P", + "opens": {"kind": "assign", "target": "x", "value": false}, + "closes": {"kind": "assign", "target": "x", "value": true} + }); + let method = json!({ + "name": "VM.Go", "file": "VM.cs", + "events": [{"ev": "assign", "target": "x", "value": false, "line": 3}] + }); + // The rule is only OBSERVABLE through a SCOPED protocol: with the + // methods gone, "the block is not a list" and "the block is empty" + // differ by exactly one thing — an empty method list makes a scoped + // rule dead, and a silenced family says nothing at all. Measured, not + // guessed: an earlier version of this control used an unscoped rule + // and a mutation reading a non-list block as empty survived it. + let scoped = json!({ + "name": "P", + "opens": {"kind": "assign", "target": "x", "value": false}, + "closes": {"kind": "assign", "target": "x", "value": true}, + "scope": {"methods": ["VM.Go"]} + }); + // The control twins first: as LISTS these same records do report. + let live = obj(&json!({ + "protocols": [&rule], "protocol_functions": [&method] + })); + assert_eq!( + protocol_findings(&live) + .iter() + .map(|f| f.code.as_str()) + .collect::>(), + vec!["OBL003"], + "the twin must report, or the assertions below prove nothing" + ); + let dead = obj(&json!({"protocols": [&scoped], "protocol_functions": []})); + assert_eq!( + protocol_findings(&dead) + .iter() + .map(|f| f.code.as_str()) + .collect::>(), + vec!["OBL005"], + "an EMPTY method list makes a scoped rule dead — which is what the \ + silenced family below must NOT do" + ); + for root in [ + json!({"protocols": "nope", "protocol_functions": [&method]}), + json!({"protocols": [&rule], "protocol_functions": "nope"}), + json!({"protocols": [&scoped], "protocol_functions": "nope"}), + json!({"protocols": {"name": "P"}, "protocol_functions": []}), + ] { + assert!( + protocol_findings(&obj(&root)).is_empty(), + "a non-list protocol block must silence the family: {root}" + ); + } + // Absent is not "not a list": both blocks default to empty, so the + // scoped rule is dead rather than silenced. + assert!(protocol_findings(&obj(&json!({}))).is_empty()); + assert!(protocol_findings(&obj(&json!({"protocols": [&rule]}))).is_empty()); + assert!(protocol_findings(&obj(&json!({"protocol_functions": [&method]}))).is_empty()); + assert_eq!( + protocol_findings(&obj(&json!({"protocols": [&scoped]}))).len(), + 1, + "an ABSENT protocol_functions block is an empty one" + ); + } + /// BR-P1 on the raw document: `disposable` counts only as the JSON `true`, /// a non-object service entry is skipped, an unknown lifetime is ignored. #[test] diff --git a/rust/crates/own-bridge/tests/verdicts.rs b/rust/crates/own-bridge/tests/verdicts.rs index acd4109a..d8939319 100644 --- a/rust/crates/own-bridge/tests/verdicts.rs +++ b/rust/crates/own-bridge/tests/verdicts.rs @@ -299,8 +299,6 @@ fn assert_exclusions_hold<'m>( ); } let expected_exclusions: BTreeSet<&str> = [ - "protocol_isloaded_clean", - "protocol_isloaded_violation", "verdict_boundary_line_negative", "verdict_boundary_line_above_u32", "verdict_boundary_service_line_negative", diff --git a/rust/crates/own-ir/src/lib.rs b/rust/crates/own-ir/src/lib.rs index ade26cac..18dc45ab 100644 --- a/rust/crates/own-ir/src/lib.rs +++ b/rust/crates/own-ir/src/lib.rs @@ -43,7 +43,7 @@ //! Error *message* parity with Python is not claimed yet — that lands with the //! shared error-text fixtures (P-022 oracle section), not by copy-paste. -mod protocol; +pub mod protocol; pub mod span; mod strict; diff --git a/rust/crates/own-ir/src/protocol.rs b/rust/crates/own-ir/src/protocol.rs index 893f2843..525dc44a 100644 --- a/rust/crates/own-ir/src/protocol.rs +++ b/rust/crates/own-ir/src/protocol.rs @@ -1,5 +1,5 @@ -//! The obligation-protocol **acceptance grammar** (P-025), ported from the -//! shared parser in `ownlang/obligations.py`. +//! The obligation-protocol **grammar** (P-025), ported from the shared parser in +//! `ownlang/obligations.py`. //! //! # Why this lives in `own-ir` at all //! @@ -10,25 +10,40 @@ //! be a smaller checkpoint, it would be a strict door with a hole in it — which //! is what the second census measured, at 47 of 58 permissive cases. //! -//! # What is ported, and what deliberately is not +//! # One grammar, two consumers (#259 checkpoint 4b) //! -//! Ported: **acceptance only** — `parse_protocol`, `parse_matcher`, -//! `parse_events`, `parse_method`. These answer "is this a well-formed protocol -//! declaration / event tree?". +//! Checkpoint 1 ported **acceptance only**: these functions answered "is this a +//! well-formed protocol declaration / event tree?" and threw the answer away, +//! because nothing consumed a typed representation. Checkpoint 4b wires the +//! obligation analysis, which needs exactly the value this module was already +//! deriving and discarding. //! -//! Not ported: everything that *uses* the answer — the obligation lattice, the -//! walker, matching, `check_protocols`, verdicts. Those are analysis, they are -//! not part of what the door accepts, and they belong to a later checkpoint. +//! So they now *validate and construct* — one implementation of the grammar, +//! read by two consumers: //! -//! Consequently these functions build no `Protocol` value for a caller to use. -//! They validate and return the record's identity, and `own-ir` keeps -//! `protocols` / `protocol_functions` as raw [`Value`]s. Parsing them into typed -//! Rust structures now would be inventing a representation before anything -//! consumes it. +//! * the **strict door** ([`crate::strict`]), which takes the record's identity +//! and drops the rest; +//! * the **analysis** (`own_analysis::obligation`), which takes the value. +//! +//! A second parser in `own-analysis` would be two interpretations of one +//! grammar, which is the drift a single authority exists to prevent — and it +//! would be the reference's shape inverted, since `obligations.py` is *one* +//! module holding the grammar, the types and the walk. +//! +//! # What is here, and what deliberately is not +//! +//! [`Matcher::matches`], [`Matcher::describe`], [`Protocol::applies_to`] and +//! [`Protocol::tracks_target`] are here because the reference defines them on +//! the dataclasses themselves: they are the values' own meaning — a predicate +//! over two facts, a phrase naming a matcher — and none of them decides +//! anything. Every verdict-deciding part (the `{OPEN, CLOSED}` lattice, the +//! walker, the definite/maybe split, emission and ordering) is +//! `own-analysis`'s, and the codes, messages and evidence slices are +//! `own-bridge`'s. //! //! # Two rules that are not about types //! -//! `parse_protocol` refuses a protocol that can never fire (no barriers with +//! [`parse_protocol`] refuses a protocol that can never fire (no barriers with //! `exit_barriers: false`) and one whose barrier equals its `opens` matcher (the //! walk checks opens first, so the barrier is dead). Every value in such a //! record has the right type and a legal vocabulary; what is wrong is that the @@ -41,18 +56,11 @@ //! against later evidence, and then filing new mechanisms under the nearest //! available name, is the precise substitution the enum was built to stop. -// `unreachable_pub` (denied workspace-wide) and `redundant_pub_crate` -// disagree about a private module's cross-module helpers: the first -// rejects `pub`, the second flags `pub(crate)`. `pub(crate)` is the one -// that states the real visibility, so the other lint is silenced here -// rather than the module being made public to satisfy it. -#![allow(clippy::redundant_pub_crate)] - use std::collections::BTreeSet; use serde_json::{Map, Value}; -use crate::strict::{defaulted_int, name_slot, optional_string, MAX_NESTING_DEPTH}; +use crate::strict::{defaulted_int_value, name_slot, optional_string, MAX_NESTING_DEPTH}; use crate::{OwnIrError, OwnIrErrorKind}; /// The closed event vocabulary of `protocol_functions[].events` — the `ev` @@ -76,31 +84,188 @@ fn well_formed(message: impl Into) -> OwnIrError { OwnIrError::new(OwnIrErrorKind::WellFormedness, message) } -/// One event pattern, in the only form this checkpoint needs: something that can -/// be compared. The `opens in barriers` rule is a value comparison in the -/// reference (a frozen dataclass), so equality here has to mean the same thing — -/// `args` is a set, not a list, because the reference stores a `frozenset`. -#[derive(Debug, PartialEq, Eq)] -struct MatcherSpec { - kind: &'static str, +/// Which shape a [`Matcher`] selects. The reference stores the discriminator as +/// a string on a frozen dataclass; an enum is the same value with the +/// vocabulary made unrepresentable-if-wrong. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum MatcherKind { + /// Matches an assign event with the same `target`. + Assign, + /// Matches a call event with the same `callee`. + Call, +} + +/// One event pattern (`obligations.Matcher`). +/// +/// - `Assign`: matches an assign event with the same `target`; `value` narrows +/// to a specific written boolean (`None` = any value, **including an opaque +/// one**). +/// - `Call`: matches a call event with the same `target` (the callee); a +/// non-empty `args` narrows to calls whose distinguished argument is in the +/// set — a call with an *unknown* argument does not match a narrowed matcher, +/// because a barrier crossing we cannot prove is not invented. +/// +/// Equality is the reference's dataclass equality, and it is load-bearing: the +/// `opens in barriers` rule below is a value comparison. `args` is a set, not a +/// list, because the reference stores a `frozenset`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Matcher { + pub kind: MatcherKind, /// assign: the member name; call: the callee name. - target: String, + pub target: String, /// assign only — `None` means "any written value". - value: Option, + pub value: Option, /// call only — empty means "any argument". - args: BTreeSet, + pub args: BTreeSet, +} + +impl Matcher { + /// Does this pattern match `event`? Non-leaf events never match. + /// + /// The narrowed-args rule is the precision policy in one line: an argument + /// the frontend could not read is `None`, and `None` is not in any set. + #[must_use] + pub fn matches(&self, event: &Event) -> bool { + match (self.kind, event) { + (MatcherKind::Assign, Event::Assign { target, value, .. }) => { + *target == self.target && (self.value.is_none() || *value == self.value) + } + (MatcherKind::Call, Event::Call { callee, arg, .. }) => { + *callee == self.target + && (self.args.is_empty() || arg.as_ref().is_some_and(|a| self.args.contains(a))) + } + _ => false, + } + } + + /// A stable, line-free human phrase for messages (`IsLoaded = true`, + /// `EndUpdate()`). The bridge interpolates it into the OBL wording and into + /// the first step of the evidence slice. + #[must_use] + pub fn describe(&self) -> String { + match self.kind { + MatcherKind::Assign => match self.value { + None => format!("{} = ...", self.target), + Some(true) => format!("{} = true", self.target), + Some(false) => format!("{} = false", self.target), + }, + MatcherKind::Call => format!("{}()", self.target), + } + } +} + +/// One project-declared obligation protocol (`obligations.Protocol`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Protocol { + pub name: String, + pub opens: Matcher, + pub closes: Matcher, + pub barriers: Vec, + pub allow: Vec, + /// `return` / `throw` / end-of-body are barriers too (the OWN001 shape: an + /// obligation may not leak out of the method). + pub exit_barriers: bool, + /// Explicit scope: method names the protocol applies to (exact, or a + /// trailing `Type.Method` suffix). Empty = every method that reports + /// events. Tight scoping is the false-positive control. + pub methods: Vec, + pub description: String, +} + +impl Protocol { + /// Is this protocol in scope for a method named `fn_name`? + #[must_use] + pub fn applies_to(&self, fn_name: &str) -> bool { + self.methods.is_empty() + || self + .methods + .iter() + .any(|m| fn_name == m || fn_name.ends_with(&format!(".{m}"))) + } + + /// Is `target` one of the flags whose assigns drive this protocol? (The + /// opaque-write discharge rule asks this.) + #[must_use] + pub fn tracks_target(&self, target: &str) -> bool { + [&self.opens, &self.closes] + .into_iter() + .any(|m| m.kind == MatcherKind::Assign && m.target == target) + } +} + +/// One event of a method's ordered tree (`obligations.Event`). +/// +/// `line` is an `i64`, not a `u32`: a protocol fact never reaches the core's +/// lowering, so it carries no `u32` coordinate domain and no clamping question +/// — the value travels to the finding's anchor exactly as the document wrote +/// it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + Assign { + target: String, + /// `None` = opaque (the frontend saw a non-literal RHS). + value: Option, + line: i64, + }, + Call { + callee: String, + /// The distinguished argument (`nameof`/string literal), if known. + arg: Option, + line: i64, + }, + Return { + line: i64, + }, + Throw { + line: i64, + }, + If { + line: i64, + then: Vec, + orelse: Vec, + }, + While { + line: i64, + body: Vec, + }, +} + +impl Event { + /// The source line this event reports, whatever its shape. Every variant + /// carries one (absent in the document reads as `0`), and the walk anchors + /// on it. + #[must_use] + pub const fn line(&self) -> i64 { + match *self { + Self::Assign { line, .. } + | Self::Call { line, .. } + | Self::Return { line } + | Self::Throw { line } + | Self::If { line, .. } + | Self::While { line, .. } => line, + } + } +} + +/// One method's ordered event tree, as reported by a frontend +/// (`obligations.MethodEvents`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MethodEvents { + pub name: String, + pub file: String, + pub events: Vec, } -/// Validate one `protocols[]` record and return the name it declares. +/// Parse one `protocols[]` record, fail-loud on any shape violation. /// /// # Errors /// [`OwnIrError`] on any shape, vocabulary or identity violation, in the /// reference parser's order. -pub(crate) fn validate_protocol(raw: &Value) -> Result<&str, OwnIrError> { +pub fn parse_protocol(raw: &Value) -> Result { let Some(obj) = raw.as_object() else { return Err(shape(format!("a protocol must be an object, got {raw}"))); }; - let name = name_slot(obj, "name", "protocol")?; + let name = name_slot(obj, "name", "protocol")?.to_owned(); let what = format!("protocol '{name}'"); // Presence is checked for BOTH before either is parsed, so a record missing @@ -113,10 +278,10 @@ pub(crate) fn validate_protocol(raw: &Value) -> Result<&str, OwnIrError> { // `require_value`: an opens/closes assign matcher must name the written // boolean — "any write opens" is not a checkable protocol. let opens = matcher(&obj["opens"], &format!("{what} 'opens'"), true)?; - matcher(&obj["closes"], &format!("{what} 'closes'"), true)?; + let closes = matcher(&obj["closes"], &format!("{what} 'closes'"), true)?; let barriers = matchers(obj, "barriers", &what)?; - matchers(obj, "allow", &what)?; + let allow = matchers(obj, "allow", &what)?; let exit_barriers = match obj.get("exit_barriers") { None => true, @@ -149,21 +314,23 @@ pub(crate) fn validate_protocol(raw: &Value) -> Result<&str, OwnIrError> { ))) } }; + let mut methods: Vec = Vec::new(); match scope.and_then(|s| s.get("methods")) { None => {} - Some(Value::Array(methods)) => { - for method in methods { + Some(Value::Array(raw_methods)) => { + for method in raw_methods { // A scope entry is a method NAME the protocol is filtered by, so // an empty or mistyped one is an identity failure. The reference // raises one message for this and for a non-array `methods`; the // ledger separates them because a missing container and an // unusable name are different defects. - if !method.as_str().is_some_and(|m| !m.is_empty()) { + let Some(m) = method.as_str().filter(|m| !m.is_empty()) else { return Err(OwnIrError::new( OwnIrErrorKind::Identity, format!("{what}: 'scope.methods' entries must be non-empty strings"), )); - } + }; + methods.push(m.to_owned()); } } Some(other) => { @@ -172,24 +339,30 @@ pub(crate) fn validate_protocol(raw: &Value) -> Result<&str, OwnIrError> { ))) } } - match obj.get("description") { - None | Some(Value::String(_)) => {} + let description = match obj.get("description") { + None => String::new(), + Some(Value::String(s)) => s.clone(), Some(other) => { return Err(shape(format!( "{what}: 'description' must be a string, got {other}" ))) } - } - Ok(name) + }; + Ok(Protocol { + name, + opens, + closes, + barriers, + allow, + exit_barriers, + methods, + description, + }) } /// `barriers` / `allow`: an array of matchers, each parsed without the /// `require_value` rule that only `opens`/`closes` carry. -fn matchers( - obj: &Map, - key: &str, - what: &str, -) -> Result, OwnIrError> { +fn matchers(obj: &Map, key: &str, what: &str) -> Result, OwnIrError> { let Some(v) = obj.get(key) else { return Ok(Vec::new()); }; @@ -203,7 +376,7 @@ fn matchers( } /// One matcher object. -fn matcher(raw: &Value, what: &str, require_value: bool) -> Result { +fn matcher(raw: &Value, what: &str, require_value: bool) -> Result { let Some(obj) = raw.as_object() else { return Err(shape(format!("{what} must be an object, got {raw}"))); }; @@ -231,8 +404,8 @@ fn matcher(raw: &Value, what: &str, require_value: bool) -> Result Result Result<(), OwnIrError> { +pub fn parse_method(raw: &Value) -> Result { let Some(obj) = raw.as_object() else { return Err(shape(format!( "a protocol function must be an object, got {raw}" ))); }; - let name = name_slot(obj, "name", "protocol function")?; + let name = name_slot(obj, "name", "protocol function")?.to_owned(); let what = format!("protocol function '{name}'"); - match obj.get("file") { - None | Some(Value::String(_)) => {} + // `raw.get("file", "?")` — absent defaults, a present non-string is refused. + let file = match obj.get("file") { + None => "?".to_owned(), + Some(Value::String(s)) => s.clone(), Some(other) => { return Err(shape(format!( "{what}: 'file' must be a string, got {other}" ))) } - } - events(obj.get("events"), &what, 0) + }; + let events = events(obj.get("events"), &what, 0)?; + Ok(MethodEvents { name, file, events }) } /// An ordered event list, recursive over `if` / `while`. @@ -303,7 +479,7 @@ pub(crate) fn validate_method(raw: &Value) -> Result<(), OwnIrError> { /// `e.get("then", [])`, so an absent arm still descends a level, whereas the /// flow walker probes for a key that may not be there and must not count what /// it did not find. Two recursions, two contracts. -fn events(raw: Option<&Value>, what: &str, depth: usize) -> Result<(), OwnIrError> { +fn events(raw: Option<&Value>, what: &str, depth: usize) -> Result, OwnIrError> { if depth > MAX_NESTING_DEPTH { return Err(shape(format!( "{what}: events nested deeper than {MAX_NESTING_DEPTH} levels" @@ -318,6 +494,7 @@ fn events(raw: Option<&Value>, what: &str, depth: usize) -> Result<(), OwnIrErro ))) } }; + let mut out: Vec = Vec::with_capacity(items.len()); for event in items { let Some(obj) = event.as_object() else { return Err(shape(format!( @@ -332,32 +509,214 @@ fn events(raw: Option<&Value>, what: &str, depth: usize) -> Result<(), OwnIrErro ))); }; // The line is checked for every kind, before the per-kind fields. - defaulted_int(obj, "line", what)?; - match kind { + let line = defaulted_int_value(obj, "line", what)?; + let next = depth.saturating_add(1); + out.push(match kind { "assign" => { - name_slot(obj, "target", &format!("{what} assign"))?; - match obj.get("value") { - None | Some(Value::Null | Value::Bool(_)) => {} + let target = name_slot(obj, "target", &format!("{what} assign"))?.to_owned(); + let value = match obj.get("value") { + None | Some(Value::Null) => None, + Some(Value::Bool(b)) => Some(*b), Some(other) => { return Err(shape(format!( "{what}: assign 'value' must be a boolean or absent \ (absent = opaque write), got {other}" ))) } + }; + Event::Assign { + target, + value, + line, } } "call" => { - name_slot(obj, "callee", &format!("{what} call"))?; + let callee = name_slot(obj, "callee", &format!("{what} call"))?.to_owned(); optional_string(obj, "arg", what)?; + Event::Call { + callee, + arg: obj + .get("arg") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + line, + } } - "if" => { - events(obj.get("then"), what, depth.saturating_add(1))?; - events(obj.get("else"), what, depth.saturating_add(1))?; + "if" => Event::If { + line, + then: events(obj.get("then"), what, next)?, + orelse: events(obj.get("else"), what, next)?, + }, + "while" => Event::While { + line, + body: events(obj.get("body"), what, next)?, + }, + "return" => Event::Return { line }, + // "throw" — EVENT_KINDS is closed, and checked above. + _ => Event::Throw { line }, + }); + } + Ok(out) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::{parse_method, parse_protocol, Event, Matcher, MatcherKind}; + use serde_json::json; + use std::collections::BTreeSet; + + fn assign(target: &str, value: Option) -> Matcher { + Matcher { + kind: MatcherKind::Assign, + target: target.to_owned(), + value, + args: BTreeSet::new(), + } + } + + /// The grammar builds the value the analysis consumes, not just a verdict + /// about the record — the whole point of checkpoint 4b's change here. + #[test] + fn a_protocol_record_becomes_its_typed_value() { + let p = parse_protocol(&json!({ + "name": "DocLoad", + "opens": {"kind": "assign", "target": "IsLoaded", "value": false}, + "closes": {"kind": "assign", "target": "IsLoaded", "value": true}, + "barriers": [{"kind": "call", "callee": "OnPropertyChanged", + "args": ["Document", "Rows"]}], + "allow": [{"kind": "call", "callee": "OnPropertyChanged", "args": ["IsBusy"]}], + "scope": {"methods": ["VM.Load"]}, + "description": "d" + })) + .unwrap(); + assert_eq!(p.name, "DocLoad"); + assert_eq!(p.opens, assign("IsLoaded", Some(false))); + assert_eq!(p.closes, assign("IsLoaded", Some(true))); + assert_eq!(p.barriers.len(), 1); + assert_eq!(p.allow.len(), 1); + assert!(p.exit_barriers, "absent 'exit_barriers' defaults to true"); + assert_eq!(p.methods, vec!["VM.Load".to_owned()]); + assert_eq!(p.description, "d"); + assert!(p.tracks_target("IsLoaded")); + assert!(!p.tracks_target("Title")); + assert!(p.applies_to("Ns.VM.Load"), "a Type.Method suffix matches"); + assert!(p.applies_to("VM.Load"), "an exact name matches"); + assert!(!p.applies_to("Ns.VM.LoadAll"), "a prefix is not a suffix"); + assert!(!p.applies_to("Ns.OtherVM.Load2")); + } + + /// `describe()` is the phrase the bridge interpolates; the opaque form is + /// the one no `opens`/`closes` can reach, and a barrier can. + #[test] + fn describe_covers_every_matcher_form() { + assert_eq!(assign("IsLoaded", Some(true)).describe(), "IsLoaded = true"); + assert_eq!( + assign("IsLoaded", Some(false)).describe(), + "IsLoaded = false" + ); + assert_eq!(assign("IsLoaded", None).describe(), "IsLoaded = ..."); + assert_eq!( + Matcher { + kind: MatcherKind::Call, + target: "EndUpdate".to_owned(), + value: None, + args: BTreeSet::new(), } - "while" => events(obj.get("body"), what, depth.saturating_add(1))?, - // "return" / "throw" carry only the line, already checked. - _ => {} + .describe(), + "EndUpdate()" + ); + } + + /// The precision policy, at the matcher: a narrowed barrier does not match + /// an argument it does not name, and does not match an argument the + /// frontend could not read. + #[test] + fn a_narrowed_call_matcher_never_matches_an_unknown_argument() { + let narrowed = Matcher { + kind: MatcherKind::Call, + target: "OnPropertyChanged".to_owned(), + value: None, + args: std::iter::once("Document".to_owned()).collect(), + }; + let call = |arg: Option<&str>| Event::Call { + callee: "OnPropertyChanged".to_owned(), + arg: arg.map(ToOwned::to_owned), + line: 1, + }; + assert!(narrowed.matches(&call(Some("Document")))); + assert!(!narrowed.matches(&call(Some("Totals")))); + assert!(!narrowed.matches(&call(None))); + let wide = Matcher { + args: BTreeSet::new(), + ..narrowed + }; + assert!(wide.matches(&call(None)), "an un-narrowed matcher matches"); + } + + /// An assign matcher with no `value` matches ANY write, the opaque one + /// included — which is what lets a barrier name a flag without a value. + #[test] + fn an_unvalued_assign_matcher_matches_every_write() { + let any = assign("IsLoaded", None); + for value in [Some(true), Some(false), None] { + assert!(any.matches(&Event::Assign { + target: "IsLoaded".to_owned(), + value, + line: 1 + })); } + assert!(!any.matches(&Event::Assign { + target: "Other".to_owned(), + value: None, + line: 1 + })); + } + + /// A method record becomes its tree, `if`/`while` nested, with the file + /// default the reference uses. + #[test] + fn a_method_record_becomes_its_event_tree() { + let m = parse_method(&json!({ + "name": "VM.Load", + "events": [ + {"ev": "assign", "target": "IsLoaded", "value": false, "line": 10}, + {"ev": "if", "line": 20, + "then": [{"ev": "call", "callee": "Notify", "arg": "Doc", "line": 21}], + "else": [{"ev": "return", "line": 22}]}, + {"ev": "while", "line": 30, "body": [{"ev": "throw", "line": 31}]} + ] + })) + .unwrap(); + assert_eq!(m.file, "?", "an absent 'file' defaults to '?'"); + let Some(Event::If { then, orelse, .. }) = m.events.get(1) else { + panic!("the second event is an if") + }; + assert_eq!( + then.first(), + Some(&Event::Call { + callee: "Notify".to_owned(), + arg: Some("Doc".to_owned()), + line: 21 + }) + ); + assert_eq!(orelse.first(), Some(&Event::Return { line: 22 })); + let Some(Event::While { body, .. }) = m.events.get(2) else { + panic!("the third event is a while") + }; + assert_eq!(body.first(), Some(&Event::Throw { line: 31 })); + } + + /// An absent `line` reads as `0`, and a negative one travels: the protocol + /// path has no `u32` coordinate domain to clamp against. + #[test] + fn an_absent_line_is_zero_and_a_negative_one_survives() { + let m = parse_method(&json!({ + "name": "m", + "events": [{"ev": "return"}, {"ev": "throw", "line": -3}] + })) + .unwrap(); + assert_eq!(m.events.first(), Some(&Event::Return { line: 0 })); + assert_eq!(m.events.get(1), Some(&Event::Throw { line: -3 })); } - Ok(()) } diff --git a/rust/crates/own-ir/src/strict.rs b/rust/crates/own-ir/src/strict.rs index 38761771..b63c8d2b 100644 --- a/rust/crates/own-ir/src/strict.rs +++ b/rust/crates/own-ir/src/strict.rs @@ -192,9 +192,23 @@ fn defaulted_string(obj: &Map, key: &str, what: &str) -> Checked /// within the representable range — see [`is_representable_int`], which carries /// both halves so no call site can get one without the other. pub(crate) fn defaulted_int(obj: &Map, key: &str, what: &str) -> Checked { + defaulted_int_value(obj, key, what).map(|_| ()) +} + +/// [`defaulted_int`], keeping the value it validated. +/// +/// The protocol grammar needs the number it just checked (an event's `line` +/// travels to a finding's anchor), and everything else needs only the verdict. +/// One implementation with two returns, rather than a second reader of the same +/// slot that could disagree about the default or the domain. +pub(crate) fn defaulted_int_value( + obj: &Map, + key: &str, + what: &str, +) -> Result { match obj.get(key) { - None => Ok(()), - Some(v) if is_representable_int(v) => Ok(()), + None => Ok(0), + Some(v) if is_representable_int(v) => Ok(v.as_i64().unwrap_or(0)), Some(other) => Err(shape(format!( "{what} '{key}' must be an integer, got {other}" ))), @@ -524,13 +538,13 @@ fn protocols(obj: &Map) -> Checked { "protocols", "OwnIR 'protocols' must be a JSON array of objects", )?; - let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); for raw in protos { - let name = crate::protocol::validate_protocol(raw)?; + let name = crate::protocol::parse_protocol(raw)?.name; // The name is the identity the bridge maps verdicts back by (IR5); two // protocols sharing one would make that mapping ambiguous and can // collapse distinct findings in the dedup. - if !seen.insert(name) { + if !seen.insert(name.clone()) { return Err(identity(format!( "duplicate protocol name '{name}' — protocol names are the \ identity findings map back by and must be unique" @@ -547,7 +561,7 @@ fn protocol_functions(obj: &Map) -> Checked { "OwnIR 'protocol_functions' must be a JSON array of objects", )?; for raw in pfns { - crate::protocol::validate_method(raw)?; + crate::protocol::parse_method(raw)?; } Ok(()) } diff --git a/scripts/render_checkpoint_status.py b/scripts/render_checkpoint_status.py index b8c8db02..39fd1596 100644 --- a/scripts/render_checkpoint_status.py +++ b/scripts/render_checkpoint_status.py @@ -24,6 +24,8 @@ * `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-cp4b-mutations.md` — checkpoint 4b's two campaigns (the + obligation ANALYSIS and its BRIDGE half), rendered the same way. * `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 @@ -88,6 +90,7 @@ CENSUS_MD = "p022-cp4-census.md" INVENTORY_MD = "p022-cp5-inventory.md" CP5_MUTATIONS_MD = "p022-cp5-mutations.md" +CP4B_MUTATIONS_MD = "p022-cp4b-mutations.md" MUTATIONS_MD = "p022-cp4-mutations.md" SHADOW_CENSUS_MD = "p022-shadow-census.md" SHADOW_MUTATIONS_MD = "p022-shadow-mutations.md" @@ -109,6 +112,14 @@ ("checkpoint 5.2 — the refusal text and the core message it quotes", "p022-cp5-2"), ("checkpoint 5.3 — the rendered surfaces", "p022-cp5-3"), ) +# Checkpoint 4b, on the same one-campaign-per-sub-checkpoint rule: the analysis +# and the bridge are measured separately because they fail separately — a walk +# that decides wrongly and a wording that phrases wrongly are different defects +# with different catchers. +CP4B_CAMPAIGNS = ( + ("checkpoint 4b.1 — the obligation analysis", "p022-cp4b-1"), + ("checkpoint 4b.2 — the bridge mapping (BR-P3)", "p022-cp4b-2"), +) SELF = "scripts/render_checkpoint_status.py" @@ -254,8 +265,8 @@ def render_inventory(inv: SurfaceInventory) -> str: "`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.", + "verbatim; `bridge-protocol` — the OBL family (BR-P3), synthesized by the " + "bridge from a violation the obligation analysis owns.", "", ] lines += _coverage_table(inv.messages) @@ -637,6 +648,19 @@ def fragments() -> tuple[dict[str, str], list[str]]: CP5_CAMPAIGNS) out[CP5_MUTATIONS_MD] = cp5 problems.extend(f"mutation campaign {p}" for p in cp5_problems) + cp4b, cp4b_problems = render_campaign_set( + "# P-022 checkpoint 4b — mutation campaigns", + "The obligation-protocol family, measured in two halves: the ANALYSIS " + "(`own-analysis/src/obligation.rs` plus the half of the shared grammar it " + "reads) and the BRIDGE mapping (BR-P3 — codes, wordings, identity " + "derivations, the evidence slice and the tolerant-door rules). 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.", + CP4B_CAMPAIGNS) + out[CP4B_MUTATIONS_MD] = cp4b + problems.extend(f"mutation campaign {p}" for p in cp4b_problems) return out, problems diff --git a/spec/Bridge.md b/spec/Bridge.md index de6639be..a2f5a508 100644 --- a/spec/Bridge.md +++ b/spec/Bridge.md @@ -304,7 +304,12 @@ anchorless. Related locations and ordered `flow` slices are attached exactly where §4/§5 say (DI consumer ctor; DI paths via `di_path_steps`; the capture escape slice subscribe-site → registration-site; flow-local origin→violation 2-step; effect re-run→mint; protocol opened→barrier). Steps with unknown lines -(`< 1`) are omitted; a slice shorter than 2 steps is dropped. +(`< 1`) are omitted; a slice shorter than 2 steps is dropped — **except on the +protocol path**: `_protocol_findings` filters unknown lines and stops there, so +an obligation leaking off the end of its method carries a **one-step** slice +(its second step would repeat the first). The port reproduces the reference; +whether this sentence or that code should move is a Python-first decision still +owed (#259 cp4b note §6.7). **BR-V6 (severity and suppression).** `advisory` findings (OWN050/051/052, OBL005) render as warnings, SARIF `note`, and are excluded from the exit code. @@ -367,8 +372,8 @@ committed regeneration path and a zero-Python steady state: 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 and fully - compared (below); #259 as a whole remains open on **row 4b** (the - obligation-protocol analysis) and the coordinate-domain decision. + compared (below); #259 as a whole remains open on the coordinate-domain + decision alone, row 4b (the obligation-protocol analysis) having landed. - **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: @@ -399,11 +404,14 @@ committed regeneration path and a zero-Python steady state: 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 + core **refuses by a declared boundary** — 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. It listed a third boundary until #259 checkpoint 4b: + a protocol-bearing document, which the bridge refused rather than answer for + with the OBL analysis unported. Both such documents are now **promoted** — + the family is ported (BR-P3), and a re-declared exclusion is a red build + rather than a note, because the replay runs every entry it names. 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). @@ -452,7 +460,8 @@ Five points belong to this spec rather than to those notes. separately; this composition does not. Nothing there is shadow mode: comparing end diagnostics as an acceptance -surface is #260's acceptance and is blocked on #259 (cp5 and 4b). +surface is #260's acceptance and is blocked on #259 — on its final acceptance, +now that cp5 and 4b have both landed. Regeneration: each layer gets a `--write` mode mirroring `tests/test_cfg_fixtures.py`; a stale committed fixture is a red build; the diff --git a/spec/BridgeBehaviorMatrix.md b/spec/BridgeBehaviorMatrix.md index cc4ffca9..521d77ea 100644 --- a/spec/BridgeBehaviorMatrix.md +++ b/spec/BridgeBehaviorMatrix.md @@ -10,7 +10,8 @@ > 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. +> which #259 cp4 left deferred and cp5 proved, and the BR-P3 protocol rows, +> which cp4b ported and promoted out of the exclusion ledger. > **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). @@ -76,7 +77,9 @@ |---|---|---|---|---| | DI graph finders' verdict sets + messages + anchor metadata (DI001/002/003/004/005 unit layer) | `ownlang/di.py` (not the bridge) | BR-B1, BR-P1 | L805–L1048 (18) | (core suite) | | advisory codes OWN051/OWN052 registered in `TITLES` (spec↔code drift guard) | `diagnostics.TITLES` | INF-P2/P3 | L1937 | — | -| effects re-validation skip-not-coerce; protocol first-wins on tolerant door | `_effect_findings`, `_protocol_findings` | BR-D2, BR-P2/P3 | (pinned in `test_effects.py` / `test_obligations.py`) | L3 | +| effects re-validation skip-not-coerce; protocol first-wins on tolerant door | `_effect_findings`, `_protocol_findings` | BR-D2, BR-P2/P3 | (pinned in `test_effects.py` / `test_obligations.py`) | L3 ✅ | +| obligation protocols: the lattice, the leaf order, the exits, the loop's single emission, the evidence and the sort key | `ownlang/obligations.py` (not the bridge) | BR-B1, BR-P3 | `test_obligations.py` §1 + `tests/test_obligation_fact_parity.py` | (core suite) | +| protocol verdict mapping: `(kind, definite)` → OBL001–004, the four line-free wordings, component/handler, the opened→barrier(→late-close) slice, the anchorless OBL005 | `_protocol_findings`, `_protocol_message` | BR-P3, BR-V4/V5/V6 | `test_obligations.py` §3 + the `verdict_protocol_*` Layer 3 cases | L3 ✅ | ## (e) Verdict mapping @@ -141,9 +144,14 @@ and the BR-V5 `related`/`flow` slices — and every refusal in full; and 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). +ledger [`p022-cp5-inventory.md`](../docs/generated/p022-cp5-inventory.md). +The **protocol rows** (§4 BR-P3) were the first of two row families outside the +replayed set: the OBL analysis had no port, so the bridge refused a +protocol-bearing document rather than return an incomplete list. #259 +checkpoint 4b closed that — the analysis is `own-analysis`'s, the typed values +come from the one grammar in `own-ir`, the bridge maps them, and both reference +documents are promoted out of `rust_replay_excluded` and replayed against the +goldens exactly as they were committed. What is still outside the replayed set, +recorded in that ledger with an executable expectation, is the coordinate +boundary (a decision #259 owes) and the tolerant-door coercions the typed Rust +constructor cannot reach (OD-1). diff --git a/tests/fixtures/obligation_fact_parity.json b/tests/fixtures/obligation_fact_parity.json new file mode 100644 index 00000000..d4ab1734 --- /dev/null +++ b/tests/fixtures/obligation_fact_parity.json @@ -0,0 +1,4976 @@ +{ + "comment": "GENERATED by tests/test_obligation_fact_parity.py --write; do not edit. Python (ownlang.obligations) is authoritative; rust/crates/own-analysis/tests/obligation_parity.rs replays the same raw protocol/event documents through the ported analysis and must reproduce every violation member and the dead-rule list exactly (#259 cp4b). Codes, messages and evidence slices are NOT here: those are the bridge's (BR-P3) and the Layer 3 verdict family owns them.", + "obligation_parity_version": 1, + "cases": [ + { + "name": "straight_line_barrier_crossing", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "close_before_the_barrier_is_clean", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + } + ] + } + ], + "expected": [], + "dead": [] + }, + { + "name": "nothing_open_is_clean", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [], + "dead": [] + }, + { + "name": "allow_beats_barrier", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 20, + "arg": "IsBusy" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [], + "dead": [] + }, + { + "name": "allow_beats_barrier_when_one_event_matches_both", + "protocols": [ + { + "name": "WideBarrier", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged" + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 20, + "arg": "IsBusy" + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "WideBarrier", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "args_narrowing_rejects_another_argument", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 20, + "arg": "Totals" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [], + "dead": [] + }, + { + "name": "args_narrowing_rejects_an_unknown_argument", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 21 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [], + "dead": [] + }, + { + "name": "args_narrowing_rejects_an_unknown_argument_with_no_allow_list", + "protocols": [ + { + "name": "DocLoadNoAllow", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 21 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [], + "dead": [] + }, + { + "name": "an_unnamed_call_is_neutral", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "RebuildIndexes", + "line": 20 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "a_bare_call_barrier_matches_any_argument", + "protocols": [ + { + "name": "BatchUpdate", + "opens": { + "kind": "call", + "callee": "BeginUpdate" + }, + "closes": { + "kind": "call", + "callee": "EndUpdate" + }, + "barriers": [ + { + "kind": "call", + "callee": "Refresh" + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "call", + "callee": "BeginUpdate", + "line": 10 + }, + { + "ev": "call", + "callee": "Refresh", + "line": 20 + }, + { + "ev": "call", + "callee": "EndUpdate", + "line": 30 + } + ] + } + ], + "expected": [ + { + "protocol": "BatchUpdate", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 20, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "Refresh()", + "close_line": 30 + } + ], + "dead": [] + }, + { + "name": "a_second_barrier_matcher_can_fire", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Rows" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Rows)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "an_assign_barrier_describes_itself_as_a_write", + "protocols": [ + { + "name": "DirtyFlag", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "assign", + "target": "Dirty" + } + ], + "allow": [] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "assign", + "target": "Dirty", + "line": 20 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DirtyFlag", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 20, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "Dirty = ...", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "a_value_narrowed_assign_barrier_ignores_the_other_write", + "protocols": [ + { + "name": "DirtyTrue", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "assign", + "target": "Dirty", + "value": true + } + ], + "allow": [] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "assign", + "target": "Dirty", + "value": false, + "line": 20 + }, + { + "ev": "assign", + "target": "Dirty", + "value": true, + "line": 21 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DirtyTrue", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 21, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "Dirty = ...", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "a_barrier_in_a_branch_stays_definite", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + } + ], + "else": [] + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "half_closed_at_a_merge_is_a_maybe", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 21 + } + ], + "else": [] + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 30, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 40 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 30, + "kind": "barrier", + "definite": false, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 40 + } + ], + "dead": [] + }, + { + "name": "open_on_some_path_is_a_maybe", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + } + ], + "else": [] + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 30, + "arg": "Document" + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 10, + "kind": "exit", + "definite": false, + "open_line": 10, + "barrier_desc": "end of method", + "close_line": null + }, + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 30, + "kind": "barrier", + "definite": false, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "reopening_keeps_the_earliest_open_site", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 20 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "a_later_reopen_does_not_move_the_provenance", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 20 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "both_arms_open_keeps_it_definite_and_joins_the_earliest_open", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 21 + } + ], + "else": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 11 + } + ] + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 30, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 30, + "kind": "barrier", + "definite": true, + "open_line": 11, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "an_open_falling_off_the_end_anchors_at_the_open", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 10, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "end of method", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "an_early_return_while_open_reports_at_the_return", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "return", + "line": 25 + } + ], + "else": [] + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 25, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "return", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "a_throw_while_open_reports_at_the_throw", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "throw", + "line": 30 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 30, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "throw", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "an_exit_leak_carries_no_late_close_hop", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "throw", + "line": 25 + } + ], + "else": [] + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 25, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "throw", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "code_after_a_partially_returning_branch_still_runs", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "return", + "line": 25 + } + ], + "else": [] + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 25, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "return", + "close_line": null + }, + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "only_the_returning_path_leaks", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "return", + "line": 25 + } + ], + "else": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 25, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "return", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "both_arms_leaving_ends_the_method", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "return", + "line": 25 + } + ], + "else": [ + { + "ev": "throw", + "line": 26 + } + ] + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 25, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "return", + "close_line": null + }, + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 26, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "throw", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "exit_barriers_false_silences_the_exit_only", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ], + "exit_barriers": false + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "exit_barriers_false_with_no_barrier_is_clean", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ], + "exit_barriers": false + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + } + ] + } + ], + "expected": [], + "dead": [] + }, + { + "name": "exit_barriers_false_silences_a_return_and_a_throw", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ], + "exit_barriers": false + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "return", + "line": 25 + } + ], + "else": [ + { + "ev": "throw", + "line": 26 + } + ] + } + ] + } + ], + "expected": [], + "dead": [] + }, + { + "name": "events_after_a_top_level_return_are_unreachable", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "return", + "line": 20 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 30 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 20, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "return", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "an_open_with_no_line_anchors_at_zero", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 0, + "kind": "exit", + "definite": true, + "open_line": 0, + "barrier_desc": "end of method", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "a_loop_may_run_zero_times_so_a_close_inside_is_a_maybe", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "while", + "line": 20, + "body": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 21 + } + ] + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 30, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 40 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 30, + "kind": "barrier", + "definite": false, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 40 + } + ], + "dead": [] + }, + { + "name": "a_barrier_in_a_loop_reports_exactly_once", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "while", + "line": 20, + "body": [ + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + } + ] + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "a_barrier_in_a_nested_loop_reports_exactly_once", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "while", + "line": 20, + "body": [ + { + "ev": "while", + "line": 21, + "body": [ + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + } + ] + } + ] + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "open_close_cycling_in_a_loop_converges_and_stays_definite", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "while", + "line": 20, + "body": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 21 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 22 + } + ] + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 30, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 40 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 30, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 40 + } + ], + "dead": [] + }, + { + "name": "a_loop_body_that_always_leaves_still_converges", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "while", + "line": 20, + "body": [ + { + "ev": "return", + "line": 21 + } + ] + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 30, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 40 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 21, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "return", + "close_line": null + }, + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 30, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 40 + } + ], + "dead": [] + }, + { + "name": "an_if_inside_a_loop_reports_once", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "while", + "line": 20, + "body": [ + { + "ev": "if", + "line": 21, + "then": [ + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + } + ], + "else": [] + } + ] + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "a_loop_inside_an_if_reports_once", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "if", + "line": 20, + "then": [ + { + "ev": "while", + "line": 21, + "body": [ + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + } + ] + } + ], + "else": [] + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "an_opaque_write_may_discharge_downgrading_to_a_maybe", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "assign", + "target": "IsLoaded", + "line": 20 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": false, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "an_opaque_write_never_opens_an_obligation", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "line": 5 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + } + ] + } + ], + "expected": [], + "dead": [] + }, + { + "name": "an_opaque_write_to_an_untracked_member_is_inert", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "assign", + "target": "Title", + "line": 20 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "an_opaque_write_leaks_off_the_end_as_a_maybe", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "assign", + "target": "IsLoaded", + "line": 20 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 10, + "kind": "exit", + "definite": false, + "open_line": 10, + "barrier_desc": "end of method", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "a_type_method_suffix_matches", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ], + "scope": { + "methods": [ + "VM.Load" + ] + } + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "an_exact_name_matches", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ], + "scope": { + "methods": [ + "Ns.VM.Load" + ] + } + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "a_prefix_is_not_a_suffix_and_a_sibling_is_out_of_scope", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ], + "scope": { + "methods": [ + "VM.Load" + ] + } + } + ], + "methods": [ + { + "name": "Ns.VM.LoadAll", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + }, + { + "name": "Ns.OtherVM.Load2", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [], + "dead": [ + "DocLoad" + ] + }, + { + "name": "a_scope_matching_nothing_is_a_dead_rule", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ], + "scope": { + "methods": [ + "VM.Misspelled" + ] + } + } + ], + "methods": [ + { + "name": "Ns.OtherVM.Reload", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + } + ] + } + ], + "expected": [], + "dead": [ + "DocLoad" + ] + }, + { + "name": "an_unscoped_protocol_is_never_a_dead_rule", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [], + "expected": [], + "dead": [] + }, + { + "name": "a_dead_rule_and_a_live_one_together", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ], + "scope": { + "methods": [ + "VM.Misspelled" + ] + } + }, + { + "name": "BatchUpdate", + "opens": { + "kind": "call", + "callee": "BeginUpdate" + }, + "closes": { + "kind": "call", + "callee": "EndUpdate" + }, + "barriers": [ + { + "kind": "call", + "callee": "Refresh" + } + ], + "scope": { + "methods": [ + "VM.Load" + ] + } + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "call", + "callee": "BeginUpdate", + "line": 10 + }, + { + "ev": "call", + "callee": "Refresh", + "line": 20 + } + ] + } + ], + "expected": [ + { + "protocol": "BatchUpdate", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 10, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "end of method", + "close_line": null + }, + { + "protocol": "BatchUpdate", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 20, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "Refresh()", + "close_line": null + } + ], + "dead": [ + "DocLoad" + ] + }, + { + "name": "two_protocols_do_not_interfere", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + }, + { + "name": "BatchUpdate", + "opens": { + "kind": "call", + "callee": "BeginUpdate" + }, + "closes": { + "kind": "call", + "callee": "EndUpdate" + }, + "barriers": [ + { + "kind": "call", + "callee": "Refresh" + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "BeginUpdate", + "line": 20 + }, + { + "ev": "call", + "callee": "EndUpdate", + "line": 30 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "two_protocols_both_fire_on_one_method", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + }, + { + "name": "BatchUpdate", + "opens": { + "kind": "call", + "callee": "BeginUpdate" + }, + "closes": { + "kind": "call", + "callee": "EndUpdate" + }, + "barriers": [ + { + "kind": "call", + "callee": "Refresh" + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "BeginUpdate", + "line": 20 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "call", + "callee": "Refresh", + "line": 51 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + }, + { + "ev": "call", + "callee": "EndUpdate", + "line": 91 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + }, + { + "protocol": "BatchUpdate", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 51, + "kind": "barrier", + "definite": true, + "open_line": 20, + "barrier_desc": "Refresh()", + "close_line": 91 + } + ], + "dead": [] + }, + { + "name": "one_protocol_over_several_methods", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.LoadA", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + }, + { + "name": "Ns.VM.LoadB", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.LoadA", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + }, + { + "protocol": "DocLoad", + "method": "Ns.VM.LoadB", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "two_barriers_sort_by_line", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 60, + "arg": "Document" + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Rows" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Rows)", + "close_line": 90 + }, + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 60, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "equal_lines_sort_by_barrier_description", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Rows" + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + }, + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Rows)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "violations_sort_by_file_before_line", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.Z.Load", + "file": "z.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 60, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + }, + { + "name": "Ns.B.Load", + "file": "b.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 70, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.B.Load", + "file": "b.cs", + "line": 70, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + }, + { + "protocol": "DocLoad", + "method": "Ns.Z.Load", + "file": "z.cs", + "line": 60, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "equal_locations_sort_by_protocol_then_barrier", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + }, + { + "name": "BatchUpdate", + "opens": { + "kind": "call", + "callee": "BeginUpdate" + }, + "closes": { + "kind": "call", + "callee": "EndUpdate" + }, + "barriers": [ + { + "kind": "call", + "callee": "Refresh" + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "BeginUpdate", + "line": 11 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "call", + "callee": "Refresh", + "line": 50 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + }, + { + "ev": "call", + "callee": "EndUpdate", + "line": 91 + } + ] + } + ], + "expected": [ + { + "protocol": "BatchUpdate", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 11, + "barrier_desc": "Refresh()", + "close_line": 91 + }, + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "the_late_close_is_the_earliest_one_after_the_barrier", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 70 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 80 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 70 + } + ], + "dead": [] + }, + { + "name": "a_close_on_the_barrier_line_is_not_late", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 50 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "a_close_before_the_barrier_is_not_a_late_close", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 20 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 30 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 90 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 30, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 90 + } + ], + "dead": [] + }, + { + "name": "an_unreachable_close_still_counts_as_evidence", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "return", + "line": 60 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 70 + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 70 + }, + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 60, + "kind": "exit", + "definite": true, + "open_line": 10, + "barrier_desc": "return", + "close_line": null + } + ], + "dead": [] + }, + { + "name": "a_close_only_inside_a_loop_body_still_counts_as_evidence", + "protocols": [ + { + "name": "DocLoad", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document", + "Rows" + ] + } + ], + "allow": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "IsBusy", + "IsLoaded" + ] + } + ] + } + ], + "methods": [ + { + "name": "Ns.VM.Load", + "file": "VM.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "line": 50, + "arg": "Document" + }, + { + "ev": "while", + "line": 60, + "body": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 70 + } + ] + } + ] + } + ], + "expected": [ + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 10, + "kind": "exit", + "definite": false, + "open_line": 10, + "barrier_desc": "end of method", + "close_line": null + }, + { + "protocol": "DocLoad", + "method": "Ns.VM.Load", + "file": "VM.cs", + "line": 50, + "kind": "barrier", + "definite": true, + "open_line": 10, + "barrier_desc": "OnPropertyChanged(Document)", + "close_line": 70 + } + ], + "dead": [] + } + ] +} diff --git a/tests/fixtures/repro/digests.json b/tests/fixtures/repro/digests.json index f25cc3a5..5d838882 100644 --- a/tests/fixtures/repro/digests.json +++ b/tests/fixtures/repro/digests.json @@ -507,6 +507,48 @@ "digest": "67a62185aec92d3afdd043816def76b1d69fce13a3fa5339c61cefb69c823504", "bytes": 493 }, + { + "case": "verdict_protocol_absent_rules_are_silent", + "corpus": "verdicts", + "digest": "6a2c86794db9155f50e8d2f49b5a0750e89bbb2308ddb3e745cbad04cba1debb", + "bytes": 1158 + }, + { + "case": "verdict_protocol_barrier_maybe_two_step_slice", + "corpus": "verdicts", + "digest": "10070bdf723cddb776b8bcddbe0ec201d4d58f9018ff8271572436b8c96c5955", + "bytes": 1309 + }, + { + "case": "verdict_protocol_beside_other_families", + "corpus": "verdicts", + "digest": "ad5e4f3f36a1d359ccb8532365801cc4043babd9176556f2a072a85bc334e0f5", + "bytes": 1709 + }, + { + "case": "verdict_protocol_exit_codes_and_wordings", + "corpus": "verdicts", + "digest": "443ca8012688649cd1f1af51d7fe31b421af9af9518c775f07ff33ff1cecfd31", + "bytes": 1679 + }, + { + "case": "verdict_protocol_identity_and_line_guards", + "corpus": "verdicts", + "digest": "addb7b7e66c3716aa048eb46a7dd49e45f17bca06c82d54bcfd3e314485c2e28", + "bytes": 1771 + }, + { + "case": "verdict_protocol_obl005_dead_rule", + "corpus": "verdicts", + "digest": "cb3e6c15db1365ee93db746d073de313083cbe592a22aaa366d83bc5019c0254", + "bytes": 2220 + }, + { + "case": "verdict_protocol_tolerant_door_rules", + "corpus": "verdicts", + "digest": "d98ab7fa30b2680cf0dbb605fec21bf62a44cd824b6bee6d98a27d23c3994edc", + "bytes": 2018 + }, { "case": "verdict_same_site_distinct_events", "corpus": "verdicts", diff --git a/tests/fixtures/repro/manifest.json b/tests/fixtures/repro/manifest.json index c9f956d2..98cbcd2a 100644 --- a/tests/fixtures/repro/manifest.json +++ b/tests/fixtures/repro/manifest.json @@ -125,7 +125,7 @@ "name": "protocol_isloaded_violation", "corpus": "ownir", "pins": [ - "a document the Rust bridge refuses by a declared #259 boundary (the obligation-protocol analysis is not ported) but the reference captures in full — the artifact records one engine's capture and takes no side on the other's" + "a document declaring an obligation protocol: the Rust bridge REFUSED it by a declared #259 boundary until checkpoint 4b ported the OBL analysis, and now produces the verdict layer like the reference. The artifact records each engine's own capture and takes no side on the other's, so what the promotion changed here is one engine's status, not a comparison" ] }, { diff --git a/tests/fixtures/repro/protocol_isloaded_violation.repro.json b/tests/fixtures/repro/protocol_isloaded_violation.repro.json index 7c3c3b5b..e34b163a 100644 --- a/tests/fixtures/repro/protocol_isloaded_violation.repro.json +++ b/tests/fixtures/repro/protocol_isloaded_violation.repro.json @@ -403,8 +403,44 @@ "projection": { "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)" + "status": "produced", + "document": { + "verdicts_version": 1, + "findings": [ + { + "file": "BigDocumentViewModel.cs", + "line": 241, + "code": "OBL001", + "component": "BigDocumentViewModel", + "event": "DocumentLoading", + "handler": "LoadBigDocument", + "message": "obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.BigDocumentViewModel.LoadBigDocument' — 'IsLoaded = true' must happen first", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "BigDocumentViewModel.cs", + 184, + "obligation 'DocumentLoading' opens here (IsLoaded = false)" + ], + [ + "BigDocumentViewModel.cs", + 241, + "barrier 'OnPropertyChanged(Document)' fires while it is open" + ], + [ + "BigDocumentViewModel.cs", + 260, + "closed here — after the barrier has already fired" + ] + ], + "ignore_reason": null, + "column": null + } + ] + } } ] } diff --git a/tests/fixtures/repro/protocol_isloaded_violation.trace.json b/tests/fixtures/repro/protocol_isloaded_violation.trace.json index 43bef8d7..029305ab 100644 --- a/tests/fixtures/repro/protocol_isloaded_violation.trace.json +++ b/tests/fixtures/repro/protocol_isloaded_violation.trace.json @@ -361,13 +361,52 @@ }, { "layer": "verdicts", - "status": "refused", + "status": "produced", "projection": { "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)", - "steps": [] + "steps": [ + { + "id": "verdicts_version", + "value": 1 + }, + { + "id": "findings[BigDocumentViewModel.cs:241:None:OBL001]", + "value": { + "file": "BigDocumentViewModel.cs", + "line": 241, + "code": "OBL001", + "component": "BigDocumentViewModel", + "event": "DocumentLoading", + "handler": "LoadBigDocument", + "message": "obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.BigDocumentViewModel.LoadBigDocument' — 'IsLoaded = true' must happen first", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "BigDocumentViewModel.cs", + 184, + "obligation 'DocumentLoading' opens here (IsLoaded = false)" + ], + [ + "BigDocumentViewModel.cs", + 241, + "barrier 'OnPropertyChanged(Document)' fires while it is open" + ], + [ + "BigDocumentViewModel.cs", + 260, + "closed here — after the barrier has already fired" + ] + ], + "ignore_reason": null, + "column": null + } + } + ] } ] } diff --git a/tests/fixtures/verdict_renders/manifest.json b/tests/fixtures/verdict_renders/manifest.json index ae5ec74f..fb01d3f2 100644 --- a/tests/fixtures/verdict_renders/manifest.json +++ b/tests/fixtures/verdict_renders/manifest.json @@ -98,6 +98,18 @@ "sarif_properties", "sarif_suppressions" ] + }, + { + "name": "render_protocol_family", + "rules": [ + "BR-P3", + "BR-V9" + ], + "pins": [ + "sarif_region_omitted", + "sarif_code_flows", + "sarif_level_note" + ] } ] } diff --git a/tests/fixtures/verdict_renders/render_protocol_family.facts.json b/tests/fixtures/verdict_renders/render_protocol_family.facts.json new file mode 100644 index 00000000..e1ec9058 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_protocol_family.facts.json @@ -0,0 +1,121 @@ +{ + "ownir_version": 0, + "module": "Protocols", + "_doc": [ + "BR-V9 over the OBL family (#259 checkpoint 4b), two surfaces in one", + "document:", + " * OBL005 is advisory AND anchorless — file '?', line 0 — so SARIF gets", + " a literal '?' artifact URI with the region omitted (OD-6) and level", + " 'note'. OWN052 already pins the anchorless shape; this pins that the", + " protocol family reaches it too, by its own route (a dead scope rule", + " rather than a failed MOS solve).", + " * OBL001 carries the three-step opened -> barrier -> late-close slice,", + " so `codeFlows` is rendered from a PROTOCOL slice. The existing", + " evidence case builds its flow from a flow-local leak; the two reach", + " the same emitter through different builders, and only bytes prove", + " the same emitter through different builders, and only bytes prove", + " the emitter did not change on the way.", + " * an OBL003 leak off the END and one through a RETURN, so the two exit", + " wordings are rendered rather than only compared as values, and Ghost's", + " scope carries TWO entries so the ', ' CPython puts between them is in", + " the bytes as well." + ], + "protocols": [ + { + "name": "Ghost", + "opens": { + "kind": "assign", + "target": "x", + "value": false + }, + "closes": { + "kind": "assign", + "target": "x", + "value": true + }, + "scope": { + "methods": [ + "GhostViewModel.Misspelled", + "AViewModel.Absent" + ] + } + }, + { + "name": "DocumentLoading", + "opens": { + "kind": "assign", + "target": "IsLoaded", + "value": false + }, + "closes": { + "kind": "assign", + "target": "IsLoaded", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "OnPropertyChanged", + "args": [ + "Document" + ] + } + ] + } + ], + "protocol_functions": [ + { + "name": "Broker.DocViewModel.Load", + "file": "DocViewModel.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 12 + }, + { + "ev": "call", + "callee": "OnPropertyChanged", + "arg": "Document", + "line": 18 + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": true, + "line": 24 + } + ] + }, + { + "name": "Broker.DocViewModel.Abandon", + "file": "DocViewModel.cs", + "events": [ + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 40 + }, + { + "ev": "if", + "line": 41, + "then": [ + { + "ev": "return", + "line": 42 + } + ], + "else": [] + }, + { + "ev": "assign", + "target": "IsLoaded", + "value": false, + "line": 50 + } + ] + } + ] +} diff --git a/tests/fixtures/verdict_renders/render_protocol_family.renders.json b/tests/fixtures/verdict_renders/render_protocol_family.renders.json new file mode 100644 index 00000000..b8544fd0 --- /dev/null +++ b/tests/fixtures/verdict_renders/render_protocol_family.renders.json @@ -0,0 +1,571 @@ +{ + "renders_version": 1, + "human": { + "error": [ + "?:0: error: [OBL005] protocol 'Ghost' is scoped to ['AViewModel.Absent', 'GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?) [resource: protocol obligation]", + "DocViewModel.cs:18: error: [OBL001] obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.DocViewModel.Load' — 'IsLoaded = true' must happen first [resource: protocol obligation]", + "DocViewModel.cs:40: error: [OBL003] obligation 'DocumentLoading' is not closed when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]", + "DocViewModel.cs:42: error: [OBL003] obligation 'DocumentLoading' is not closed when 'Broker.DocViewModel.Abandon' exits via return — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + ], + "warning": [ + "?:0: warning: [OBL005] protocol 'Ghost' is scoped to ['AViewModel.Absent', 'GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?) [resource: protocol obligation]", + "DocViewModel.cs:18: warning: [OBL001] obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.DocViewModel.Load' — 'IsLoaded = true' must happen first [resource: protocol obligation]", + "DocViewModel.cs:40: warning: [OBL003] obligation 'DocumentLoading' is not closed when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]", + "DocViewModel.cs:42: warning: [OBL003] obligation 'DocumentLoading' is not closed when 'Broker.DocViewModel.Abandon' exits via return — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + ] + }, + "github": { + "error": [ + "::error file=?,line=0,title=OBL005::[OBL005] protocol 'Ghost' is scoped to ['AViewModel.Absent', 'GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?) [resource: protocol obligation]", + "::error file=DocViewModel.cs,line=18,title=OBL001::[OBL001] obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.DocViewModel.Load' — 'IsLoaded = true' must happen first [resource: protocol obligation]", + "::error file=DocViewModel.cs,line=40,title=OBL003::[OBL003] obligation 'DocumentLoading' is not closed when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]", + "::error file=DocViewModel.cs,line=42,title=OBL003::[OBL003] obligation 'DocumentLoading' is not closed when 'Broker.DocViewModel.Abandon' exits via return — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + ], + "warning": [ + "::warning file=?,line=0,title=OBL005::[OBL005] protocol 'Ghost' is scoped to ['AViewModel.Absent', 'GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?) [resource: protocol obligation]", + "::warning file=DocViewModel.cs,line=18,title=OBL001::[OBL001] obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.DocViewModel.Load' — 'IsLoaded = true' must happen first [resource: protocol obligation]", + "::warning file=DocViewModel.cs,line=40,title=OBL003::[OBL003] obligation 'DocumentLoading' is not closed when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]", + "::warning file=DocViewModel.cs,line=42,title=OBL003::[OBL003] obligation 'DocumentLoading' is not closed when 'Broker.DocViewModel.Abandon' exits via return — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + ] + }, + "msbuild": { + "error": [ + "?(0): error OBL005: protocol 'Ghost' is scoped to ['AViewModel.Absent', 'GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?) [resource: protocol obligation]", + "DocViewModel.cs(18): error OBL001: obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.DocViewModel.Load' — 'IsLoaded = true' must happen first [resource: protocol obligation]", + "DocViewModel.cs(40): error OBL003: obligation 'DocumentLoading' is not closed when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]", + "DocViewModel.cs(42): error OBL003: obligation 'DocumentLoading' is not closed when 'Broker.DocViewModel.Abandon' exits via return — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + ], + "warning": [ + "?(0): warning OBL005: protocol 'Ghost' is scoped to ['AViewModel.Absent', 'GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?) [resource: protocol obligation]", + "DocViewModel.cs(18): warning OBL001: obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.DocViewModel.Load' — 'IsLoaded = true' must happen first [resource: protocol obligation]", + "DocViewModel.cs(40): warning OBL003: obligation 'DocumentLoading' is not closed when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]", + "DocViewModel.cs(42): warning OBL003: obligation 'DocumentLoading' is not closed when 'Broker.DocViewModel.Abandon' exits via return — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + ] + }, + "unknown-format": { + "error": [ + "?:0: error: [OBL005] protocol 'Ghost' is scoped to ['AViewModel.Absent', 'GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?) [resource: protocol obligation]", + "DocViewModel.cs:18: error: [OBL001] obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.DocViewModel.Load' — 'IsLoaded = true' must happen first [resource: protocol obligation]", + "DocViewModel.cs:40: error: [OBL003] obligation 'DocumentLoading' is not closed when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]", + "DocViewModel.cs:42: error: [OBL003] obligation 'DocumentLoading' is not closed when 'Broker.DocViewModel.Abandon' exits via return — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + ], + "warning": [ + "?:0: warning: [OBL005] protocol 'Ghost' is scoped to ['AViewModel.Absent', 'GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?) [resource: protocol obligation]", + "DocViewModel.cs:18: warning: [OBL001] obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.DocViewModel.Load' — 'IsLoaded = true' must happen first [resource: protocol obligation]", + "DocViewModel.cs:40: warning: [OBL003] obligation 'DocumentLoading' is not closed when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]", + "DocViewModel.cs:42: warning: [OBL003] obligation 'DocumentLoading' is not closed when 'Broker.DocViewModel.Abandon' exits via return — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + ] + }, + "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": "OBL001", + "shortDescription": { + "text": "obligation still open when a barrier fires (open on every path)" + } + }, + { + "id": "OBL003", + "shortDescription": { + "text": "obligation not closed before the method exits (on every path)" + } + }, + { + "id": "OBL005", + "shortDescription": { + "text": "protocol scope matched no reported method -- rule is dead (advisory)" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OBL005", + "level": "note", + "message": { + "text": "protocol 'Ghost' is scoped to ['AViewModel.Absent', 'GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?) [resource: protocol obligation]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "?" + } + } + } + ], + "properties": { + "resourceKind": "protocol obligation", + "component": "?", + "event": "Ghost" + } + }, + { + "ruleId": "OBL001", + "level": "error", + "message": { + "text": "obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.DocViewModel.Load' — 'IsLoaded = true' must happen first [resource: protocol obligation]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 18 + } + } + } + ], + "properties": { + "resourceKind": "protocol obligation", + "component": "DocViewModel", + "event": "DocumentLoading", + "handler": "Load" + }, + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 12 + } + }, + "message": { + "text": "obligation 'DocumentLoading' opens here (IsLoaded = false)" + } + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 18 + } + }, + "message": { + "text": "barrier 'OnPropertyChanged(Document)' fires while it is open" + } + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 24 + } + }, + "message": { + "text": "closed here — after the barrier has already fired" + } + } + } + ] + } + ] + } + ] + }, + { + "ruleId": "OBL003", + "level": "error", + "message": { + "text": "obligation 'DocumentLoading' is not closed when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 40 + } + } + } + ], + "properties": { + "resourceKind": "protocol obligation", + "component": "DocViewModel", + "event": "DocumentLoading", + "handler": "Abandon" + }, + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 40 + } + }, + "message": { + "text": "obligation 'DocumentLoading' opens here (IsLoaded = false)" + } + } + } + ] + } + ] + } + ] + }, + { + "ruleId": "OBL003", + "level": "error", + "message": { + "text": "obligation 'DocumentLoading' is not closed when 'Broker.DocViewModel.Abandon' exits via return — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 42 + } + } + } + ], + "properties": { + "resourceKind": "protocol obligation", + "component": "DocViewModel", + "event": "DocumentLoading", + "handler": "Abandon" + }, + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 40 + } + }, + "message": { + "text": "obligation 'DocumentLoading' opens here (IsLoaded = false)" + } + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 42 + } + }, + "message": { + "text": "the method exits here via return while it is open" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + }, + "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": "OBL001", + "shortDescription": { + "text": "obligation still open when a barrier fires (open on every path)" + } + }, + { + "id": "OBL003", + "shortDescription": { + "text": "obligation not closed before the method exits (on every path)" + } + }, + { + "id": "OBL005", + "shortDescription": { + "text": "protocol scope matched no reported method -- rule is dead (advisory)" + } + } + ], + "properties": { + "ownirSchemaVersion": 0 + } + } + }, + "results": [ + { + "ruleId": "OBL005", + "level": "note", + "message": { + "text": "protocol 'Ghost' is scoped to ['AViewModel.Absent', 'GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?) [resource: protocol obligation]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "?" + } + } + } + ], + "properties": { + "resourceKind": "protocol obligation", + "component": "?", + "event": "Ghost" + } + }, + { + "ruleId": "OBL001", + "level": "warning", + "message": { + "text": "obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.DocViewModel.Load' — 'IsLoaded = true' must happen first [resource: protocol obligation]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 18 + } + } + } + ], + "properties": { + "resourceKind": "protocol obligation", + "component": "DocViewModel", + "event": "DocumentLoading", + "handler": "Load" + }, + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 12 + } + }, + "message": { + "text": "obligation 'DocumentLoading' opens here (IsLoaded = false)" + } + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 18 + } + }, + "message": { + "text": "barrier 'OnPropertyChanged(Document)' fires while it is open" + } + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 24 + } + }, + "message": { + "text": "closed here — after the barrier has already fired" + } + } + } + ] + } + ] + } + ] + }, + { + "ruleId": "OBL003", + "level": "warning", + "message": { + "text": "obligation 'DocumentLoading' is not closed when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 40 + } + } + } + ], + "properties": { + "resourceKind": "protocol obligation", + "component": "DocViewModel", + "event": "DocumentLoading", + "handler": "Abandon" + }, + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 40 + } + }, + "message": { + "text": "obligation 'DocumentLoading' opens here (IsLoaded = false)" + } + } + } + ] + } + ] + } + ] + }, + { + "ruleId": "OBL003", + "level": "warning", + "message": { + "text": "obligation 'DocumentLoading' is not closed when 'Broker.DocViewModel.Abandon' exits via return — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths) [resource: protocol obligation]" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 42 + } + } + } + ], + "properties": { + "resourceKind": "protocol obligation", + "component": "DocViewModel", + "event": "DocumentLoading", + "handler": "Abandon" + }, + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 40 + } + }, + "message": { + "text": "obligation 'DocumentLoading' opens here (IsLoaded = false)" + } + } + }, + { + "location": { + "physicalLocation": { + "artifactLocation": { + "uri": "DocViewModel.cs" + }, + "region": { + "startLine": 42 + } + }, + "message": { + "text": "the method exits here via return while it is open" + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + } +} diff --git a/tests/fixtures/verdicts/manifest.json b/tests/fixtures/verdicts/manifest.json index 5f3fed3a..0d52a09d 100644 --- a/tests/fixtures/verdicts/manifest.json +++ b/tests/fixtures/verdicts/manifest.json @@ -2,18 +2,6 @@ "comment": "The frozen Layer 3 case ledger (#259 cp4/cp5). The swept corpora (tests/fixtures/{ownir,lowered,summaries}) are discovered automatically; the synthetic verdict cases beside this manifest are listed exhaustively with the Bridge.md rules they pin; rust_replay_excluded names the cases whose golden is Python's truth but which the Rust core refuses by a DECLARED boundary — each with its reason and the executable expectation the Rust replay asserts (rust_refusal: 'bridge' = own_bridge::check_facts returns an error containing rust_error_contains; 'door' = the typed OwnIr constructor refuses the document, #294 OD-1). Python renders every case; an exclusion is a statement about the port, never about the reference.", "verdicts_version": 1, "rust_replay_excluded": [ - { - "name": "protocol_isloaded_clean", - "reason": "declares an obligation protocol: the OBL001-005 analysis (ownlang/obligations.py) has no own-analysis port, and the bridge refuses a protocol-bearing document rather than return a verdict list with a family missing (#259 boundary; the reference reports no finding here, which a vacuous replay would have 'matched').", - "rust_refusal": "bridge", - "rust_error_contains": "obligation protocol" - }, - { - "name": "protocol_isloaded_violation", - "reason": "declares an obligation protocol and the reference reports OBL001 at BigDocumentViewModel.cs:241; the OBL analysis is not wired, so the bridge refuses the document (#259 boundary).", - "rust_refusal": "bridge", - "rust_error_contains": "obligation protocol" - }, { "name": "verdict_boundary_line_negative", "reason": "coordinate outside the core's u32 line domain (a negative param line the strict door admits per spec/OwnIR.md §4.2); the reference anchors OWN001 at B.cs:-1, the Rust core refuses instead of clamping — declared cp4 divergence family.", @@ -261,6 +249,61 @@ "BR-V4", "BR-V7" ] + }, + { + "name": "verdict_protocol_barrier_maybe_two_step_slice", + "rules": [ + "BR-P3", + "BR-V4", + "BR-V5" + ] + }, + { + "name": "verdict_protocol_exit_codes_and_wordings", + "rules": [ + "BR-P3", + "BR-V4", + "BR-V5", + "BR-V8" + ] + }, + { + "name": "verdict_protocol_obl005_dead_rule", + "rules": [ + "BR-P3", + "BR-V5", + "BR-V6" + ] + }, + { + "name": "verdict_protocol_tolerant_door_rules", + "rules": [ + "BR-D2", + "BR-P3" + ] + }, + { + "name": "verdict_protocol_identity_and_line_guards", + "rules": [ + "BR-P3", + "BR-V5" + ] + }, + { + "name": "verdict_protocol_absent_rules_are_silent", + "rules": [ + "BR-D2", + "BR-P3", + "BR-V1" + ] + }, + { + "name": "verdict_protocol_beside_other_families", + "rules": [ + "BR-P3", + "BR-V1", + "BR-V8" + ] } ] } diff --git a/tests/fixtures/verdicts/verdict_protocol_absent_rules_are_silent.facts.json b/tests/fixtures/verdicts/verdict_protocol_absent_rules_are_silent.facts.json new file mode 100644 index 00000000..b2df7e99 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_absent_rules_are_silent.facts.json @@ -0,0 +1,42 @@ +{ + "ownir_version": 0, + "module": "Silent", + "components": [ + { + "name": "GridViewModel", + "file": "GridViewModel.cs", + "subscriptions": [ + { + "event": "grid.View.CellValueChanged", + "handler": "OnCellChanged", + "line": 20, + "resource": "unresolved-subscription" + } + ] + } + ], + "_doc": [ + "BR-P3: no PARSEABLE protocol means no protocol findings, even when the", + "method events would have fired one. The only protocols[] record here is", + "malformed (no opens/closes), so the tolerant door skips it and the family", + "produces nothing — while the event tree below is a textbook open ->", + "barrier -> close crossing that a surviving rule would have reported.", + "The document is not silent overall: the unresolved subscription still", + "yields its OWN050 advisory, so the ABSENCE of an OBL row is a positive", + "observation about this golden rather than an empty file." + ], + "protocols": [ + {"name": "NeverParsed", "barriers": [{"kind": "call", "callee": "Publish"}]} + ], + "protocol_functions": [ + { + "name": "Broker.GridViewModel.Reload", + "file": "GridViewModel.cs", + "events": [ + {"ev": "assign", "target": "IsLoaded", "value": false, "line": 30}, + {"ev": "call", "callee": "Publish", "line": 31}, + {"ev": "assign", "target": "IsLoaded", "value": true, "line": 32} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_absent_rules_are_silent.verdicts.json b/tests/fixtures/verdicts/verdict_protocol_absent_rules_are_silent.verdicts.json new file mode 100644 index 00000000..058e2dd7 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_absent_rules_are_silent.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "GridViewModel.cs", + "line": 20, + "code": "OWN050", + "component": "GridViewModel", + "event": "grid.View.CellValueChanged", + "handler": "OnCellChanged", + "message": "cannot verify 'grid.View.CellValueChanged' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped", + "kind": "unresolved reference", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_barrier_maybe_two_step_slice.facts.json b/tests/fixtures/verdicts/verdict_protocol_barrier_maybe_two_step_slice.facts.json new file mode 100644 index 00000000..cef39096 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_barrier_maybe_two_step_slice.facts.json @@ -0,0 +1,42 @@ +{ + "ownir_version": 0, + "module": "Docs", + "components": [], + "_doc": [ + "BR-P3: the (barrier, maybe) cell of the code table — OBL002 — and the", + "TWO-step evidence slice, which no document in the corpus reached before", + "checkpoint 4b: the reference's only protocol golden closes after the", + "barrier and therefore carries the three-step form.", + "The obligation is closed on ONE arm of the branch, so at the notification", + "it is open on some path but not every path; the only close in the tree is", + "BEFORE the barrier, so there is no late-close hop to attach." + ], + "protocols": [ + { + "name": "DocumentLoading", + "opens": {"kind": "assign", "target": "IsLoaded", "value": false}, + "closes": {"kind": "assign", "target": "IsLoaded", "value": true}, + "barriers": [ + {"kind": "call", "callee": "OnPropertyChanged", "args": ["Document"]} + ], + "allow": [ + {"kind": "call", "callee": "OnPropertyChanged", "args": ["IsBusy"]} + ], + "scope": {"methods": ["ReloadViewModel.Reload"]} + } + ], + "protocol_functions": [ + { + "name": "Broker.ReloadViewModel.Reload", + "file": "ReloadViewModel.cs", + "events": [ + {"ev": "assign", "target": "IsLoaded", "value": false, "line": 10}, + {"ev": "call", "callee": "OnPropertyChanged", "arg": "IsBusy", "line": 12}, + {"ev": "if", "line": 20, + "then": [{"ev": "assign", "target": "IsLoaded", "value": true, "line": 21}], + "else": []}, + {"ev": "call", "callee": "OnPropertyChanged", "arg": "Document", "line": 30} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_barrier_maybe_two_step_slice.verdicts.json b/tests/fixtures/verdicts/verdict_protocol_barrier_maybe_two_step_slice.verdicts.json new file mode 100644 index 00000000..597039e7 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_barrier_maybe_two_step_slice.verdicts.json @@ -0,0 +1,54 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "ReloadViewModel.cs", + "line": 10, + "code": "OBL004", + "component": "ReloadViewModel", + "event": "DocumentLoading", + "handler": "Reload", + "message": "obligation 'DocumentLoading' may not be closed (open on some path) when the method falls off the end — the object is published in its in-between state; close with 'IsLoaded = true' on every path (a finally block covers the throw paths)", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "ReloadViewModel.cs", + 10, + "obligation 'DocumentLoading' opens here (IsLoaded = false)" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "ReloadViewModel.cs", + "line": 30, + "code": "OBL002", + "component": "ReloadViewModel", + "event": "DocumentLoading", + "handler": "Reload", + "message": "obligation 'DocumentLoading' may still be open (open on some path) when barrier 'OnPropertyChanged(Document)' fires in 'Broker.ReloadViewModel.Reload' — 'IsLoaded = true' must happen first", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "ReloadViewModel.cs", + 10, + "obligation 'DocumentLoading' opens here (IsLoaded = false)" + ], + [ + "ReloadViewModel.cs", + 30, + "barrier 'OnPropertyChanged(Document)' fires while it is open" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_beside_other_families.facts.json b/tests/fixtures/verdicts/verdict_protocol_beside_other_families.facts.json new file mode 100644 index 00000000..ce94f4ef --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_beside_other_families.facts.json @@ -0,0 +1,66 @@ +{ + "ownir_version": 0, + "module": "Mixed", + "components": [ + { + "name": "GridViewModel", + "file": "Mixed.cs", + "subscriptions": [ + { + "event": "grid.View.CellValueChanged", + "handler": "OnCellChanged", + "line": 40, + "resource": "unresolved-subscription" + } + ] + } + ], + "_doc": [ + "BR-V1 / BR-V8: the protocol family is APPENDED to the pipeline, not a", + "replacement for it. One document produces a DI001, an EFF001, an OBL001", + "and an OWN050, all anchored in the same file, and the final list is the", + "BR-V8 sort over the four — (file, line, column, code) with ties keeping", + "insertion order.", + "The append POSITION itself (protocols after effects, before OWN050) is", + "not observable end to end: two findings from different families never", + "share a code, so the code component of the sort key decides their order", + "before insertion order can. Recorded in the checkpoint note rather than", + "pretended to be a control here." + ], + "services": [ + {"name": "App", "lifetime": "singleton", "file": "Mixed.cs", "line": 5, + "deps": ["Db"]}, + {"name": "Db", "lifetime": "scoped", "file": "Mixed.cs", "line": 6} + ], + "effects": [ + { + "component": "Dashboard", + "file": "Mixed.cs", + "line": 12, + "io": true, + "deps": ["filters"], + "bindings": [{"name": "filters", "init": "object", "refs": [], "line": 11}] + } + ], + "protocols": [ + { + "name": "DocumentLoading", + "opens": {"kind": "assign", "target": "IsLoaded", "value": false}, + "closes": {"kind": "assign", "target": "IsLoaded", "value": true}, + "barriers": [ + {"kind": "call", "callee": "OnPropertyChanged", "args": ["Document"]} + ] + } + ], + "protocol_functions": [ + { + "name": "Broker.MixedViewModel.Load", + "file": "Mixed.cs", + "events": [ + {"ev": "assign", "target": "IsLoaded", "value": false, "line": 20}, + {"ev": "call", "callee": "OnPropertyChanged", "arg": "Document", "line": 25}, + {"ev": "assign", "target": "IsLoaded", "value": true, "line": 28} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_beside_other_families.verdicts.json b/tests/fixtures/verdicts/verdict_protocol_beside_other_families.verdicts.json new file mode 100644 index 00000000..bc77b946 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_beside_other_families.verdicts.json @@ -0,0 +1,107 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Mixed.cs", + "line": 5, + "code": "DI001", + "component": "App", + "event": "Db", + "handler": "", + "message": "singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db)", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Mixed.cs", + 5, + "singleton 'App' (captor)" + ], + [ + "Mixed.cs", + 6, + "captures scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Mixed.cs", + "line": 12, + "code": "EFF001", + "component": "Dashboard", + "event": "filters", + "handler": "", + "message": "effect re-runs on every render: dependency 'filters' 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 'filters' with useMemo/useCallback (or move it out of render)", + "kind": "react effect", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Mixed.cs", + 12, + "effect re-runs here on 'filters'" + ], + [ + "Mixed.cs", + 11, + "'filters' gets a fresh identity here — stabilise with useMemo" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Mixed.cs", + "line": 25, + "code": "OBL001", + "component": "MixedViewModel", + "event": "DocumentLoading", + "handler": "Load", + "message": "obligation 'DocumentLoading' is still open when barrier 'OnPropertyChanged(Document)' fires in 'Broker.MixedViewModel.Load' — 'IsLoaded = true' must happen first", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Mixed.cs", + 20, + "obligation 'DocumentLoading' opens here (IsLoaded = false)" + ], + [ + "Mixed.cs", + 25, + "barrier 'OnPropertyChanged(Document)' fires while it is open" + ], + [ + "Mixed.cs", + 28, + "closed here — after the barrier has already fired" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Mixed.cs", + "line": 40, + "code": "OWN050", + "component": "GridViewModel", + "event": "grid.View.CellValueChanged", + "handler": "OnCellChanged", + "message": "cannot verify 'grid.View.CellValueChanged' — its declaring type is an unresolved reference (build the project or pass references); leakage analysis skipped", + "kind": "unresolved reference", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_exit_codes_and_wordings.facts.json b/tests/fixtures/verdicts/verdict_protocol_exit_codes_and_wordings.facts.json new file mode 100644 index 00000000..27f2c254 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_exit_codes_and_wordings.facts.json @@ -0,0 +1,61 @@ +{ + "ownir_version": 0, + "module": "Batch", + "components": [], + "_doc": [ + "BR-P3: every exit cell of the code table and every exit wording, none of", + "which the corpus reached before checkpoint 4b.", + " Ret -> OBL003 at the return, 'exits via return'", + " Thr -> OBL003 at the throw, 'exits via throw'", + " Maybe -> OBL004 (open on one arm only), 'may not be closed'", + " Fall -> OBL003 anchored at the OPEN (the OWN001 anchor-at-acquire", + " precedent), worded 'the method falls off the end', and carrying", + " a ONE-step evidence slice: its second step would repeat the", + " first, so the bridge does not build one.", + "The protocol declares no barriers at all, which is legal while", + "exit_barriers is on — so nothing here can be mistaken for a barrier row." + ], + "protocols": [ + { + "name": "Suppress", + "description": "notifications are suppressed while _suppress is up", + "opens": {"kind": "assign", "target": "_suppress", "value": true}, + "closes": {"kind": "assign", "target": "_suppress", "value": false} + } + ], + "protocol_functions": [ + { + "name": "Broker.BatchViewModel.Ret", + "file": "BatchViewModel.cs", + "events": [ + {"ev": "assign", "target": "_suppress", "value": true, "line": 10}, + {"ev": "return", "line": 20} + ] + }, + { + "name": "Broker.BatchViewModel.Thr", + "file": "BatchViewModel.cs", + "events": [ + {"ev": "assign", "target": "_suppress", "value": true, "line": 30}, + {"ev": "throw", "line": 40} + ] + }, + { + "name": "Broker.BatchViewModel.Maybe", + "file": "BatchViewModel.cs", + "events": [ + {"ev": "if", "line": 50, + "then": [{"ev": "assign", "target": "_suppress", "value": true, "line": 51}], + "else": []}, + {"ev": "return", "line": 60} + ] + }, + { + "name": "Broker.BatchViewModel.Fall", + "file": "BatchViewModel.cs", + "events": [ + {"ev": "assign", "target": "_suppress", "value": true, "line": 70} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_exit_codes_and_wordings.verdicts.json b/tests/fixtures/verdicts/verdict_protocol_exit_codes_and_wordings.verdicts.json new file mode 100644 index 00000000..6dd3c157 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_exit_codes_and_wordings.verdicts.json @@ -0,0 +1,108 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "BatchViewModel.cs", + "line": 20, + "code": "OBL003", + "component": "BatchViewModel", + "event": "Suppress", + "handler": "Ret", + "message": "obligation 'Suppress' is not closed when 'Broker.BatchViewModel.Ret' exits via return — the object is published in its in-between state; close with '_suppress = false' on every path (a finally block covers the throw paths)", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "BatchViewModel.cs", + 10, + "obligation 'Suppress' opens here (_suppress = true)" + ], + [ + "BatchViewModel.cs", + 20, + "the method exits here via return while it is open" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "BatchViewModel.cs", + "line": 40, + "code": "OBL003", + "component": "BatchViewModel", + "event": "Suppress", + "handler": "Thr", + "message": "obligation 'Suppress' is not closed when 'Broker.BatchViewModel.Thr' exits via throw — the object is published in its in-between state; close with '_suppress = false' on every path (a finally block covers the throw paths)", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "BatchViewModel.cs", + 30, + "obligation 'Suppress' opens here (_suppress = true)" + ], + [ + "BatchViewModel.cs", + 40, + "the method exits here via throw while it is open" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "BatchViewModel.cs", + "line": 60, + "code": "OBL004", + "component": "BatchViewModel", + "event": "Suppress", + "handler": "Maybe", + "message": "obligation 'Suppress' may not be closed (open on some path) when 'Broker.BatchViewModel.Maybe' exits via return — the object is published in its in-between state; close with '_suppress = false' on every path (a finally block covers the throw paths)", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "BatchViewModel.cs", + 51, + "obligation 'Suppress' opens here (_suppress = true)" + ], + [ + "BatchViewModel.cs", + 60, + "the method exits here via return while it is open" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "BatchViewModel.cs", + "line": 70, + "code": "OBL003", + "component": "BatchViewModel", + "event": "Suppress", + "handler": "Fall", + "message": "obligation 'Suppress' is not closed when the method falls off the end — the object is published in its in-between state; close with '_suppress = false' on every path (a finally block covers the throw paths)", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "BatchViewModel.cs", + 70, + "obligation 'Suppress' opens here (_suppress = true)" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_identity_and_line_guards.facts.json b/tests/fixtures/verdicts/verdict_protocol_identity_and_line_guards.facts.json new file mode 100644 index 00000000..4848b8ae --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_identity_and_line_guards.facts.json @@ -0,0 +1,113 @@ +{ + "ownir_version": 0, + "module": "Names", + "components": [], + "_doc": [ + "BR-P3 identity derivation and the BR-V5 line guard.", + " Bare -> a method name with NO dot: component and handler are both", + " the whole name (rsplit('.', 2)[-2] is not reached).", + " Vm.Two -> exactly one dot: component 'Vm', handler 'Two'.", + " Bare/Zero -> an open event with no 'line' reads as 0, so the 'opens", + " here' step is dropped (< 1) and the leak anchors at 0:", + " Bare keeps only its exit step (one), and Zero — which", + " falls off the end, so its anchor IS the open — is left", + " with an EMPTY slice.", + " .Leading / a..b -> the awkward splits: the reference takes the", + " second-to-last segment of a BOUNDED rsplit, so both name an", + " EMPTY component, and both implementations must agree on that", + " rather than on a tidier guess.", + "Nothing here is clamped or invented: line 0 travels to the finding's", + "anchor exactly as the document wrote it." + ], + "protocols": [ + { + "name": "Guard", + "opens": { + "kind": "assign", + "target": "x", + "value": true + }, + "closes": { + "kind": "assign", + "target": "x", + "value": false + } + } + ], + "protocol_functions": [ + { + "name": "Bare", + "file": "Bare.cs", + "events": [ + { + "ev": "assign", + "target": "x", + "value": true + }, + { + "ev": "throw", + "line": 4 + } + ] + }, + { + "name": "Vm.Two", + "file": "Two.cs", + "events": [ + { + "ev": "assign", + "target": "x", + "value": true, + "line": 6 + }, + { + "ev": "return", + "line": 9 + } + ] + }, + { + "name": "Zero", + "file": "Zero.cs", + "events": [ + { + "ev": "assign", + "target": "x", + "value": true + } + ] + }, + { + "name": ".Leading", + "file": "Odd.cs", + "events": [ + { + "ev": "assign", + "target": "x", + "value": true, + "line": 12 + }, + { + "ev": "return", + "line": 13 + } + ] + }, + { + "name": "a..b", + "file": "Odd.cs", + "events": [ + { + "ev": "assign", + "target": "x", + "value": true, + "line": 20 + }, + { + "ev": "return", + "line": 21 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_identity_and_line_guards.verdicts.json b/tests/fixtures/verdicts/verdict_protocol_identity_and_line_guards.verdicts.json new file mode 100644 index 00000000..ac450f07 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_identity_and_line_guards.verdicts.json @@ -0,0 +1,124 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Bare.cs", + "line": 4, + "code": "OBL003", + "component": "Bare", + "event": "Guard", + "handler": "Bare", + "message": "obligation 'Guard' is not closed when 'Bare' exits via throw — the object is published in its in-between state; close with 'x = false' on every path (a finally block covers the throw paths)", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Bare.cs", + 4, + "the method exits here via throw while it is open" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Odd.cs", + "line": 13, + "code": "OBL003", + "component": "", + "event": "Guard", + "handler": "Leading", + "message": "obligation 'Guard' is not closed when '.Leading' exits via return — the object is published in its in-between state; close with 'x = false' on every path (a finally block covers the throw paths)", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Odd.cs", + 12, + "obligation 'Guard' opens here (x = true)" + ], + [ + "Odd.cs", + 13, + "the method exits here via return while it is open" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Odd.cs", + "line": 21, + "code": "OBL003", + "component": "", + "event": "Guard", + "handler": "b", + "message": "obligation 'Guard' is not closed when 'a..b' exits via return — the object is published in its in-between state; close with 'x = false' on every path (a finally block covers the throw paths)", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Odd.cs", + 20, + "obligation 'Guard' opens here (x = true)" + ], + [ + "Odd.cs", + 21, + "the method exits here via return while it is open" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Two.cs", + "line": 9, + "code": "OBL003", + "component": "Vm", + "event": "Guard", + "handler": "Two", + "message": "obligation 'Guard' is not closed when 'Vm.Two' exits via return — the object is published in its in-between state; close with 'x = false' on every path (a finally block covers the throw paths)", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Two.cs", + 6, + "obligation 'Guard' opens here (x = true)" + ], + [ + "Two.cs", + 9, + "the method exits here via return while it is open" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Zero.cs", + "line": 0, + "code": "OBL003", + "component": "Zero", + "event": "Guard", + "handler": "Zero", + "message": "obligation 'Guard' is not closed when the method falls off the end — the object is published in its in-between state; close with 'x = false' on every path (a finally block covers the throw paths)", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_obl005_dead_rule.facts.json b/tests/fixtures/verdicts/verdict_protocol_obl005_dead_rule.facts.json new file mode 100644 index 00000000..19387b25 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_obl005_dead_rule.facts.json @@ -0,0 +1,63 @@ +{ + "ownir_version": 0, + "module": "Ghosts", + "components": [], + "_doc": [ + "BR-P3 / BR-V5 / OD-6: OBL005, the dead-rule advisory. A scoped protocol", + "that matches no reported method is surfaced honestly and never as a", + "verdict: advisory, and ANCHORLESS — file '?', line 0, component '?',", + "handler ''. Its SARIF projection is OD-6 (a literal '?' artifact URI with", + "the region omitted), pinned by the rendered-surface family.", + "Three protocols, three shapes of the message's scope list:", + " Ghost -> one entry", + " Phantom -> two entries, printed SORTED and separated by ', ', which", + " is CPython's repr of a list of strings, not this bridge's", + " choice of separator", + " Apostrophe -> one entry containing a single quote, so the element goes", + " through repr's quote switch (CPython emits \" rather than", + " escaping the ' it holds). A scope entry is project", + " configuration and can hold any string; the rule that", + " renders it is the same one cp5.2 had to fix on the", + " map-or-raise path.", + "Live beside them: Watched, whose scope DOES match, so the advisory is", + "not the only thing the family can produce here." + ], + "protocols": [ + { + "name": "Ghost", + "opens": {"kind": "assign", "target": "x", "value": false}, + "closes": {"kind": "assign", "target": "x", "value": true}, + "scope": {"methods": ["GhostViewModel.Misspelled"]} + }, + { + "name": "Phantom", + "opens": {"kind": "call", "callee": "BeginUpdate"}, + "closes": {"kind": "call", "callee": "EndUpdate"}, + "scope": {"methods": ["ZViewModel.Load", "AViewModel.Load"]} + }, + { + "name": "Apostrophe", + "opens": {"kind": "assign", "target": "y", "value": false}, + "closes": {"kind": "assign", "target": "y", "value": true}, + "scope": {"methods": ["It's.Gone"]} + }, + { + "name": "Watched", + "opens": {"kind": "assign", "target": "IsLoaded", "value": false}, + "closes": {"kind": "assign", "target": "IsLoaded", "value": true}, + "barriers": [{"kind": "call", "callee": "Publish"}], + "scope": {"methods": ["LiveViewModel.Load"]} + } + ], + "protocol_functions": [ + { + "name": "Broker.LiveViewModel.Load", + "file": "LiveViewModel.cs", + "events": [ + {"ev": "assign", "target": "IsLoaded", "value": false, "line": 5}, + {"ev": "call", "callee": "Publish", "line": 8}, + {"ev": "assign", "target": "IsLoaded", "value": true, "line": 11} + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_obl005_dead_rule.verdicts.json b/tests/fixtures/verdicts/verdict_protocol_obl005_dead_rule.verdicts.json new file mode 100644 index 00000000..cc131c45 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_obl005_dead_rule.verdicts.json @@ -0,0 +1,85 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "?", + "line": 0, + "code": "OBL005", + "component": "?", + "event": "Ghost", + "handler": "", + "message": "protocol 'Ghost' is scoped to ['GhostViewModel.Misspelled'] but no reported method matches — the rule is dead (typo in scope.methods?)", + "kind": "protocol obligation", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "?", + "line": 0, + "code": "OBL005", + "component": "?", + "event": "Phantom", + "handler": "", + "message": "protocol 'Phantom' is scoped to ['AViewModel.Load', 'ZViewModel.Load'] but no reported method matches — the rule is dead (typo in scope.methods?)", + "kind": "protocol obligation", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "?", + "line": 0, + "code": "OBL005", + "component": "?", + "event": "Apostrophe", + "handler": "", + "message": "protocol 'Apostrophe' is scoped to [\"It's.Gone\"] but no reported method matches — the rule is dead (typo in scope.methods?)", + "kind": "protocol obligation", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "LiveViewModel.cs", + "line": 8, + "code": "OBL001", + "component": "LiveViewModel", + "event": "Watched", + "handler": "Load", + "message": "obligation 'Watched' is still open when barrier 'Publish()' fires in 'Broker.LiveViewModel.Load' — 'IsLoaded = true' must happen first", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "LiveViewModel.cs", + 5, + "obligation 'Watched' opens here (IsLoaded = false)" + ], + [ + "LiveViewModel.cs", + 8, + "barrier 'Publish()' fires while it is open" + ], + [ + "LiveViewModel.cs", + 11, + "closed here — after the barrier has already fired" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_tolerant_door_rules.facts.json b/tests/fixtures/verdicts/verdict_protocol_tolerant_door_rules.facts.json new file mode 100644 index 00000000..0e9a0039 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_tolerant_door_rules.facts.json @@ -0,0 +1,115 @@ +{ + "ownir_version": 0, + "module": "Tolerant", + "components": [], + "_doc": [ + "BR-D2 / BR-P3 on the tolerant door, three rules in one document:", + " * a malformed protocol_functions[] entry is SKIPPED AS A WHOLE, and the", + " entries AFTER it are still read — 'Broker.Bad.Events' carries an event", + " outside the closed vocabulary and is listed FIRST on purpose, so a port", + " that stopped at the first bad record would report nothing at all;", + " * a malformed protocols[] entry is skipped the same way ('Broken' states", + " no opens/closes);", + " * a duplicate protocol name resolves FIRST-WINS, deterministically — the", + " name is the identity a verdict maps back by, so the second record is", + " dropped WHOLE rather than merged. The two 'Dup' records name DIFFERENT", + " barriers (Notify at line 20, Refresh at line 21), so keeping the second", + " would add a finding at a different line rather than a duplicate the", + " BR-V7 dedup would quietly absorb — which is what made an earlier", + " version of this case unable to see the rule at all.", + "The strict door REJECTS this document (it fail-louds on both malformed", + "records and on the duplicate name); that asymmetry is BR-D1 vs BR-D2 and is", + "the point of the case." + ], + "protocols": [ + { + "name": "Dup", + "opens": { + "kind": "assign", + "target": "A", + "value": false + }, + "closes": { + "kind": "assign", + "target": "A", + "value": true + }, + "barriers": [ + { + "kind": "call", + "callee": "Notify" + } + ] + }, + { + "name": "Dup", + "opens": { + "kind": "call", + "callee": "BeginUpdate" + }, + "closes": { + "kind": "call", + "callee": "EndUpdate" + }, + "barriers": [ + { + "kind": "call", + "callee": "Refresh" + } + ] + }, + { + "name": "Broken" + } + ], + "protocol_functions": [ + { + "name": "Broker.Bad.Events", + "file": "Bad.cs", + "events": [ + { + "ev": "goto", + "line": 1 + } + ] + }, + { + "name": "Broker.Go.Run", + "file": "Go.cs", + "events": [ + { + "ev": "assign", + "target": "A", + "value": false, + "line": 10 + }, + { + "ev": "call", + "callee": "BeginUpdate", + "line": 11 + }, + { + "ev": "call", + "callee": "Notify", + "line": 20 + }, + { + "ev": "call", + "callee": "Refresh", + "line": 21 + }, + { + "ev": "assign", + "target": "A", + "value": true, + "line": 30 + }, + { + "ev": "call", + "callee": "EndUpdate", + "line": 31 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_protocol_tolerant_door_rules.verdicts.json b/tests/fixtures/verdicts/verdict_protocol_tolerant_door_rules.verdicts.json new file mode 100644 index 00000000..ab62ec3e --- /dev/null +++ b/tests/fixtures/verdicts/verdict_protocol_tolerant_door_rules.verdicts.json @@ -0,0 +1,37 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Go.cs", + "line": 20, + "code": "OBL001", + "component": "Go", + "event": "Dup", + "handler": "Run", + "message": "obligation 'Dup' is still open when barrier 'Notify()' fires in 'Broker.Go.Run' — 'A = true' must happen first", + "kind": "protocol obligation", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Go.cs", + 10, + "obligation 'Dup' opens here (A = false)" + ], + [ + "Go.cs", + 20, + "barrier 'Notify()' fires while it is open" + ], + [ + "Go.cs", + 30, + "closed here — after the barrier has already fired" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/test_checkpoint_status.py b/tests/test_checkpoint_status.py index e195bb46..a5d786dd 100644 --- a/tests/test_checkpoint_status.py +++ b/tests/test_checkpoint_status.py @@ -43,6 +43,8 @@ from render_checkpoint_status import ( # noqa: E402 CAMPAIGN, CENSUS_MD, + CP4B_CAMPAIGNS, + CP4B_MUTATIONS_MD, CP5_CAMPAIGNS, CP5_MUTATIONS_MD, INVENTORY_MD, @@ -58,7 +60,7 @@ # nobody listed is a campaign nobody re-anchors. DEFINITIONS = (CAMPAIGN, *(os.path.join(EVIDENCE, f"{campaign}.json") - for _, campaign in (*CP5_CAMPAIGNS, *SHADOW_CAMPAIGNS))) + for _, campaign in (*CP4B_CAMPAIGNS, *CP5_CAMPAIGNS, *SHADOW_CAMPAIGNS))) def _anchors() -> list[str]: @@ -88,8 +90,8 @@ def run() -> int: if problems or anchors: return 1 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"{CP4B_MUTATIONS_MD}, {CP5_MUTATIONS_MD}, {SHADOW_CENSUS_MD}, " + f"{SHADOW_MUTATIONS_MD} in sync with the evidence; " f"{len(DEFINITIONS)} campaign definitions still anchor") return 0 diff --git a/tests/test_obligation_fact_parity.py b/tests/test_obligation_fact_parity.py new file mode 100644 index 00000000..5a0f302d --- /dev/null +++ b/tests/test_obligation_fact_parity.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""Fact-level differential parity for the obligation-protocol analysis +(OBL001-005, P-025) — P-022 #259 checkpoint 4b. + +The third OwnIR-fact sidecar analysis, beside `tests/test_di_eff_fact_parity.py` +(DI001-005 and EFF001) and built the same way: there is no `.own` surface, so +Python remains the reference. This generator states protocol **rules** and +per-method **event trees** as the raw documents a frontend emits, runs the +*real* `ownlang.obligations.check_protocols` / `unmatched_scopes` over them, and +freezes each violation WHOLE — `protocol`, `method`, `file`, `line`, `kind`, +`definite`, `open_line`, `barrier_desc`, `close_line` — plus the dead-rule list, +into `tests/fixtures/obligation_fact_parity.json`. +`rust/crates/own-analysis/tests/obligation_parity.rs` replays the same +documents through the ported analysis and must reproduce the exact ordered +list, with **zero Python**. + +Two things this family deliberately does NOT do: + +* it does not freeze codes, messages or evidence slices. Those are the bridge's + (`spec/Bridge.md` BR-P3), they are Layer 3, and the verdict fixture family + already owns that surface. A violation is the *facts* a finding is + synthesized from; keeping the two apart is what makes a divergence say + whether the walk or the phrasing drifted. +* it does not hand the two sides pre-parsed values. Cases carry the raw + `protocols[]` / `protocol_functions[]` records, so each side builds them with + its own half of the shared grammar — Python's `parse_protocol` / `parse_method` + and Rust's `own_ir::protocol`. A grammar that accepted the same documents and + built different values would otherwise be invisible here. + +The cases are seeded from `tests/test_obligations.py` §1 (the core walk), which +is the behaviour map this port has to reproduce, and extended with the shapes +that suite asserts through other layers or not at all: the sort key across +files, a protocol over several methods, an open with no line, and nested +control flow. + +Run: python tests/test_obligation_fact_parity.py (verify) + python tests/test_obligation_fact_parity.py --write (regenerate) + 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__), "..")) + +from ownlang.obligations import ( + MethodEvents, + Protocol, + Violation, + check_protocols, + parse_method, + parse_protocol, + unmatched_scopes, +) + +FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", + "obligation_fact_parity.json") + +# --- the rule side, as documents --------------------------------------------- + +#: The canonical protocol of `tests/test_obligations.py`: `IsLoaded = false` +#: opens, `IsLoaded = true` closes, `OnPropertyChanged(Document|Rows)` is a +#: barrier, `OnPropertyChanged(IsBusy|IsLoaded)` is allowed. +DOC_LOAD: dict[str, Any] = { + "name": "DocLoad", + "opens": {"kind": "assign", "target": "IsLoaded", "value": False}, + "closes": {"kind": "assign", "target": "IsLoaded", "value": True}, + "barriers": [{"kind": "call", "callee": "OnPropertyChanged", + "args": ["Document", "Rows"]}], + "allow": [{"kind": "call", "callee": "OnPropertyChanged", + "args": ["IsBusy", "IsLoaded"]}], +} + +#: A call-driven protocol: `BeginUpdate()` opens, `EndUpdate()` closes, and the +#: barrier is a bare call with no argument narrowing. +BATCH_UPDATE: dict[str, Any] = { + "name": "BatchUpdate", + "opens": {"kind": "call", "callee": "BeginUpdate"}, + "closes": {"kind": "call", "callee": "EndUpdate"}, + "barriers": [{"kind": "call", "callee": "Refresh"}], +} + + +#: A protocol whose barrier matches EVERY `OnPropertyChanged` call and whose +#: allow list names one argument — the only shape in which `allow` can actually +#: beat a barrier. `DOC_LOAD`'s barrier and allow argument sets are disjoint, so +#: no event of its can match both, and the rule is unobservable there (the +#: reference's own suite has the same blind spot). +WIDE_BARRIER: dict[str, Any] = dict( + DOC_LOAD, name="WideBarrier", + barriers=[{"kind": "call", "callee": "OnPropertyChanged"}], + allow=[{"kind": "call", "callee": "OnPropertyChanged", "args": ["IsBusy"]}]) + +#: `DOC_LOAD` without its allow list. An event with an UNKNOWN argument matches +#: neither a narrowed barrier nor a narrowed allow entry, so with an allow list +#: present the two rules mask each other; this protocol isolates the barrier. +NO_ALLOW: dict[str, Any] = dict(DOC_LOAD, name="DocLoadNoAllow", allow=[]) + +#: A protocol whose barrier is an *assign* matcher rather than a call — the +#: `target = ...` description branch, which no call barrier can reach. +DIRTY_FLAG: dict[str, Any] = dict( + DOC_LOAD, name="DirtyFlag", + barriers=[{"kind": "assign", "target": "Dirty"}], allow=[]) + +#: The same, narrowed to one written value: a write of the other value is not a +#: crossing. +DIRTY_TRUE: dict[str, Any] = dict( + DOC_LOAD, name="DirtyTrue", + barriers=[{"kind": "assign", "target": "Dirty", "value": True}], allow=[]) + + +def _proto(**kw: Any) -> dict[str, Any]: + out = dict(DOC_LOAD) + out.update(kw) + return out + + +# --- the fact side, as documents --------------------------------------------- + +def _open(line: int = 10) -> dict[str, Any]: + return {"ev": "assign", "target": "IsLoaded", "value": False, "line": line} + + +def _close(line: int = 90) -> dict[str, Any]: + return {"ev": "assign", "target": "IsLoaded", "value": True, "line": line} + + +def _opaque(target: str = "IsLoaded", line: int = 20) -> dict[str, Any]: + return {"ev": "assign", "target": target, "line": line} + + +def _notify(arg: str | None = "Document", line: int = 50) -> dict[str, Any]: + ev: dict[str, Any] = {"ev": "call", "callee": "OnPropertyChanged", "line": line} + if arg is not None: + ev["arg"] = arg + return ev + + +def _call(callee: str, line: int) -> dict[str, Any]: + return {"ev": "call", "callee": callee, "line": line} + + +def _if(line: int, then: list[dict[str, Any]], + orelse: list[dict[str, Any]] | None = None) -> dict[str, Any]: + return {"ev": "if", "line": line, "then": then, "else": orelse or []} + + +def _while(line: int, body: list[dict[str, Any]]) -> dict[str, Any]: + return {"ev": "while", "line": line, "body": body} + + +def _method(*events: dict[str, Any], name: str = "Ns.VM.Load", + file: str = "VM.cs") -> dict[str, Any]: + return {"name": name, "file": file, "events": list(events)} + + +# --- the cases --------------------------------------------------------------- +# +# Each entry is (name, protocol documents, method documents). The comment on a +# case names the behaviour it is the control for; the checkpoint note +# (docs/notes/p022-bridge-verdict-checkpoint4b.md) carries the full ledger. + +_CASES: list[tuple[str, list[dict[str, Any]], list[dict[str, Any]]]] = [ + # -- the straight line, and its fixed twin --------------------------------- + ("straight_line_barrier_crossing", + [DOC_LOAD], [_method(_open(), _notify(), _close())]), + ("close_before_the_barrier_is_clean", + [DOC_LOAD], [_method(_open(), _close(), _notify())]), + ("nothing_open_is_clean", + [DOC_LOAD], [_method(_notify(), _close())]), + + # -- matching --------------------------------------------------------------- + ("allow_beats_barrier", + [DOC_LOAD], [_method(_open(), _notify("IsBusy", 20), _close())]), + ("allow_beats_barrier_when_one_event_matches_both", + [WIDE_BARRIER], [_method(_open(), _notify("IsBusy", 20), _notify(), _close())]), + ("args_narrowing_rejects_another_argument", + [DOC_LOAD], [_method(_open(), _notify("Totals", 20), _close())]), + ("args_narrowing_rejects_an_unknown_argument", + [DOC_LOAD], [_method(_open(), _notify(None, 21), _close())]), + ("args_narrowing_rejects_an_unknown_argument_with_no_allow_list", + [NO_ALLOW], [_method(_open(), _notify(None, 21), _close())]), + ("an_unnamed_call_is_neutral", + [DOC_LOAD], [_method(_open(), _call("RebuildIndexes", 20), _notify(), _close())]), + ("a_bare_call_barrier_matches_any_argument", + [BATCH_UPDATE], [_method(_call("BeginUpdate", 10), _call("Refresh", 20), + _call("EndUpdate", 30))]), + ("a_second_barrier_matcher_can_fire", + [DOC_LOAD], [_method(_open(), _notify("Rows", 50), _close())]), + ("an_assign_barrier_describes_itself_as_a_write", + [DIRTY_FLAG], [_method(_open(), _opaque("Dirty"), _close())]), + ("a_value_narrowed_assign_barrier_ignores_the_other_write", + [DIRTY_TRUE], [_method(_open(), {"ev": "assign", "target": "Dirty", + "value": False, "line": 20}, + {"ev": "assign", "target": "Dirty", + "value": True, "line": 21}, _close())]), + + # -- the definite/maybe split ---------------------------------------------- + ("a_barrier_in_a_branch_stays_definite", + [DOC_LOAD], [_method(_open(), _if(20, [_notify()]), _close())]), + ("half_closed_at_a_merge_is_a_maybe", + [DOC_LOAD], [_method(_open(), _if(20, [_close(21)]), _notify(line=30), + _close(40))]), + ("open_on_some_path_is_a_maybe", + [DOC_LOAD], [_method(_if(20, [_open()]), _notify(line=30))]), + ("reopening_keeps_the_earliest_open_site", + [DOC_LOAD], [_method(_open(20), _open(10), _notify(), _close())]), + ("a_later_reopen_does_not_move_the_provenance", + [DOC_LOAD], [_method(_open(10), _open(20), _notify(), _close())]), + ("both_arms_open_keeps_it_definite_and_joins_the_earliest_open", + [DOC_LOAD], [_method(_if(20, [_open(21)], [_open(11)]), _notify(line=30), + _close())]), + + # -- exits ------------------------------------------------------------------- + ("an_open_falling_off_the_end_anchors_at_the_open", + [DOC_LOAD], [_method(_open())]), + ("an_early_return_while_open_reports_at_the_return", + [DOC_LOAD], [_method(_open(), _if(20, [{"ev": "return", "line": 25}]), + _close())]), + ("a_throw_while_open_reports_at_the_throw", + [DOC_LOAD], [_method(_open(), {"ev": "throw", "line": 30})]), + ("an_exit_leak_carries_no_late_close_hop", + [DOC_LOAD], [_method(_open(), _if(20, [{"ev": "throw", "line": 25}]), _close())]), + ("code_after_a_partially_returning_branch_still_runs", + [DOC_LOAD], [_method(_open(), _if(20, [{"ev": "return", "line": 25}]), + _notify(), _close())]), + ("only_the_returning_path_leaks", + [DOC_LOAD], [_method(_open(), _if(20, [{"ev": "return", "line": 25}], [_close()]))]), + ("both_arms_leaving_ends_the_method", + [DOC_LOAD], [_method(_open(), _if(20, [{"ev": "return", "line": 25}], + [{"ev": "throw", "line": 26}]), _notify())]), + ("exit_barriers_false_silences_the_exit_only", + [_proto(exit_barriers=False)], [_method(_open(), _notify(), _close())]), + ("exit_barriers_false_with_no_barrier_is_clean", + [_proto(exit_barriers=False)], [_method(_open())]), + ("exit_barriers_false_silences_a_return_and_a_throw", + [_proto(exit_barriers=False)], + [_method(_open(), _if(20, [{"ev": "return", "line": 25}], + [{"ev": "throw", "line": 26}]))]), + ("events_after_a_top_level_return_are_unreachable", + [DOC_LOAD], [_method(_open(), {"ev": "return", "line": 20}, _open(30), + _notify())]), + ("an_open_with_no_line_anchors_at_zero", + [DOC_LOAD], [_method({"ev": "assign", "target": "IsLoaded", "value": False})]), + + # -- loops --------------------------------------------------------------------- + ("a_loop_may_run_zero_times_so_a_close_inside_is_a_maybe", + [DOC_LOAD], [_method(_open(), _while(20, [_close(21)]), _notify(line=30), + _close(40))]), + ("a_barrier_in_a_loop_reports_exactly_once", + [DOC_LOAD], [_method(_open(), _while(20, [_notify()]), _close())]), + ("a_barrier_in_a_nested_loop_reports_exactly_once", + [DOC_LOAD], [_method(_open(), _while(20, [_while(21, [_notify()])]), _close())]), + ("open_close_cycling_in_a_loop_converges_and_stays_definite", + [DOC_LOAD], [_method(_open(), _while(20, [_close(21), _open(22)]), + _notify(line=30), _close(40))]), + ("a_loop_body_that_always_leaves_still_converges", + [DOC_LOAD], [_method(_open(), _while(20, [{"ev": "return", "line": 21}]), + _notify(line=30), _close(40))]), + ("an_if_inside_a_loop_reports_once", + [DOC_LOAD], [_method(_open(), _while(20, [_if(21, [_notify()])]), _close())]), + ("a_loop_inside_an_if_reports_once", + [DOC_LOAD], [_method(_open(), _if(20, [_while(21, [_notify()])]), _close())]), + + # -- the opaque-write asymmetry --------------------------------------------- + ("an_opaque_write_may_discharge_downgrading_to_a_maybe", + [DOC_LOAD], [_method(_open(), _opaque(), _notify(), _close())]), + ("an_opaque_write_never_opens_an_obligation", + [DOC_LOAD], [_method(_opaque(line=5), _notify())]), + ("an_opaque_write_to_an_untracked_member_is_inert", + [DOC_LOAD], [_method(_open(), _opaque("Title"), _notify(), _close())]), + ("an_opaque_write_leaks_off_the_end_as_a_maybe", + [DOC_LOAD], [_method(_open(), _opaque())]), + + # -- scope --------------------------------------------------------------------- + ("a_type_method_suffix_matches", + [_proto(scope={"methods": ["VM.Load"]})], + [_method(_open(), _notify(), _close())]), + ("an_exact_name_matches", + [_proto(scope={"methods": ["Ns.VM.Load"]})], + [_method(_open(), _notify(), _close())]), + ("a_prefix_is_not_a_suffix_and_a_sibling_is_out_of_scope", + [_proto(scope={"methods": ["VM.Load"]})], + [_method(_open(), _notify(), _close(), name="Ns.VM.LoadAll"), + _method(_open(), _notify(), _close(), name="Ns.OtherVM.Load2")]), + ("a_scope_matching_nothing_is_a_dead_rule", + [_proto(scope={"methods": ["VM.Misspelled"]})], + [_method(_open(), name="Ns.OtherVM.Reload")]), + ("an_unscoped_protocol_is_never_a_dead_rule", + [DOC_LOAD], []), + ("a_dead_rule_and_a_live_one_together", + [_proto(scope={"methods": ["VM.Misspelled"]}), + dict(BATCH_UPDATE, scope={"methods": ["VM.Load"]})], + [_method(_call("BeginUpdate", 10), _call("Refresh", 20))]), + + # -- several protocols, several methods -------------------------------------- + ("two_protocols_do_not_interfere", + [DOC_LOAD, BATCH_UPDATE], + [_method(_open(), _call("BeginUpdate", 20), _call("EndUpdate", 30), + _notify(), _close())]), + ("two_protocols_both_fire_on_one_method", + [DOC_LOAD, BATCH_UPDATE], + [_method(_open(), _call("BeginUpdate", 20), _notify(), _call("Refresh", 51), + _close(), _call("EndUpdate", 91))]), + ("one_protocol_over_several_methods", + [DOC_LOAD], + [_method(_open(), _notify(), _close(), name="Ns.VM.LoadA"), + _method(_open(), _notify(), _close(), name="Ns.VM.LoadB")]), + + # -- ordering ------------------------------------------------------------------ + ("two_barriers_sort_by_line", + [DOC_LOAD], [_method(_open(), _notify(line=60), _notify("Rows", 50), _close())]), + ("equal_lines_sort_by_barrier_description", + [DOC_LOAD], [_method(_open(), _notify("Rows", 50), _notify("Document", 50), + _close())]), + ("violations_sort_by_file_before_line", + [DOC_LOAD], + [_method(_open(), _notify(line=60), _close(), name="Ns.Z.Load", file="z.cs"), + _method(_open(), _notify(line=70), _close(), name="Ns.B.Load", file="b.cs")]), + ("equal_locations_sort_by_protocol_then_barrier", + [DOC_LOAD, BATCH_UPDATE], + [_method(_open(), _call("BeginUpdate", 11), _notify(line=50), + _call("Refresh", 50), _close(), _call("EndUpdate", 91))]), + + # -- the late-close hop ---------------------------------------------------------- + ("the_late_close_is_the_earliest_one_after_the_barrier", + [DOC_LOAD], [_method(_open(), _notify(), _close(70), _close(80))]), + ("a_close_on_the_barrier_line_is_not_late", + [DOC_LOAD], [_method(_open(), _notify(), _close(50))]), + ("a_close_before_the_barrier_is_not_a_late_close", + [DOC_LOAD], [_method(_open(), _close(20), _open(30), _notify(), _close(90))]), + ("an_unreachable_close_still_counts_as_evidence", + [DOC_LOAD], + [_method(_open(), _notify(), {"ev": "return", "line": 60}, _close(70))]), + ("a_close_only_inside_a_loop_body_still_counts_as_evidence", + [DOC_LOAD], [_method(_open(), _notify(), _while(60, [_close(70)]))]), +] + + +# --- running the reference ------------------------------------------------------ + +def _violation_row(v: Violation) -> dict[str, Any]: + """One violation, whole. Member order is the reference dataclass's, so a + reader can diff a row against `obligations.Violation` by eye.""" + return { + "protocol": v.protocol, + "method": v.method, + "file": v.file, + "line": v.line, + "kind": v.kind, + "definite": v.definite, + "open_line": v.open_line, + "barrier_desc": v.barrier_desc, + "close_line": v.close_line, + } + + +def _run(protocols: list[dict[str, Any]], + methods: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]: + parsed_protocols: list[Protocol] = [parse_protocol(p) for p in protocols] + parsed_methods: list[MethodEvents] = [parse_method(m) for m in methods] + violations = [_violation_row(v) + for v in check_protocols(parsed_protocols, parsed_methods)] + dead = [p.name for p in unmatched_scopes(parsed_protocols, parsed_methods)] + return violations, dead + + +def build() -> dict[str, Any]: + cases: list[dict[str, Any]] = [] + for name, protocols, methods in _CASES: + violations, dead = _run(protocols, methods) + cases.append({ + "name": name, + "protocols": protocols, + "methods": methods, + "expected": violations, + "dead": dead, + }) + return { + "comment": ( + "GENERATED by tests/test_obligation_fact_parity.py --write; do not edit. " + "Python (ownlang.obligations) is authoritative; " + "rust/crates/own-analysis/tests/obligation_parity.rs replays the same raw " + "protocol/event documents through the ported analysis and must reproduce " + "every violation member and the dead-rule list exactly (#259 cp4b). " + "Codes, messages and evidence slices are NOT here: those are the bridge's " + "(BR-P3) and the Layer 3 verdict family owns them." + ), + "obligation_parity_version": 1, + "cases": cases, + } + + +def _render(data: dict[str, Any]) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + +def _duplicate_case_names() -> list[str]: + seen: set[str] = set() + dupes: list[str] = [] + for name, _, _ in _CASES: + if name in seen: + dupes.append(name) + seen.add(name) + return dupes + + +def run() -> int: + dupes = _duplicate_case_names() + if dupes: + print(f"FAIL[obligation-parity]: duplicate case name(s) {dupes} — a case " + f"name is how a divergence is reported, so it must be unique") + return 1 + expected = _render(build()) + if not os.path.exists(FIXTURE): + print(f"FAIL[obligation-parity]: {FIXTURE} missing; regenerate with " + f"'python tests/test_obligation_fact_parity.py --write'") + return 1 + with open(FIXTURE, encoding="utf-8") as f: + actual = f.read() + if actual != expected: + print(f"FAIL[obligation-parity]: {FIXTURE} is stale (the walk, the sort key " + f"or a case changed); regenerate with " + f"'python tests/test_obligation_fact_parity.py --write' and re-run the " + f"Rust side (cd rust && cargo test -p own-analysis)") + return 1 + data = json.loads(actual) + n_violations = sum(len(c["expected"]) for c in data["cases"]) + n_dead = sum(len(c["dead"]) for c in data["cases"]) + silent = sum(1 for c in data["cases"] if not c["expected"]) + print(f"obligation fact parity OK: {len(data['cases'])} cases " + f"({n_violations} violations, {n_dead} dead rules, {silent} silence " + f"controls) verified in sync") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + os.makedirs(os.path.dirname(FIXTURE), exist_ok=True) + with open(FIXTURE, "w", encoding="utf-8") as f: + f.write(_render(build())) + print(f"wrote {FIXTURE}") + raise SystemExit(0) + raise SystemExit(run()) diff --git a/tests/test_verdict_fixtures.py b/tests/test_verdict_fixtures.py index de7364ee..a7f91a23 100644 --- a/tests/test_verdict_fixtures.py +++ b/tests/test_verdict_fixtures.py @@ -30,9 +30,11 @@ view anchor, and the declared-boundary controls below. * **The Rust exclusion ledger** — `rust_replay_excluded` names the cases whose golden is Python's truth but which the Rust core REFUSES by a declared - boundary (a protocol-bearing document: the OBL analysis is not wired; a - coordinate outside the core's `u32` line domain; a shape the typed Rust door - rejects before the bridge runs — #294 OD-1). Each entry carries its reason + boundary (a coordinate outside the core's `u32` line domain; a shape the + typed Rust door rejects before the bridge runs — #294 OD-1). It named a + third until #259 checkpoint 4b — a protocol-bearing document, refused while + the OBL analysis had no port — and both such documents are now promoted. + Each entry carries its reason and an executable expectation (`rust_refusal`: `bridge` or `door`, plus an error substring) that the Rust replay asserts, so an exclusion cannot rot into a coverage hole: the day Rust accepts one, its suite goes red demanding diff --git a/tests/verdict_surface_inventory.py b/tests/verdict_surface_inventory.py index bcbe54d7..e9ad88ee 100644 --- a/tests/verdict_surface_inventory.py +++ b/tests/verdict_surface_inventory.py @@ -114,9 +114,6 @@ def matches(self, f: dict[str, object]) -> bool: 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 " @@ -319,11 +316,32 @@ def _flow_local(bid: str, code: str, kind: str, what: str, tail: str, 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 protocol family (BR-P3). Bridge-synthesized like the matrix above, + # but from a violation rather than a handle record, so it is its own + # source: the analysis owns the verdict, the bridge owns the sentence. + # Deliberately line-free — OwnAudit fingerprints on (path, rule, message). + Branch("obl001_barrier_definite", "BR-V4", PROTOCOL, + "OBL001: a barrier fires while the obligation is open on every path", + rf"obligation '{N}' is still open when barrier '{N}' fires in '{N}' — " + rf"'{N}' must happen first", ("OBL001",)), + Branch("obl002_barrier_maybe", "BR-V4", PROTOCOL, + "OBL002: a barrier fires while it is open on some path", + rf"obligation '{N}' may still be open \(open on some path\) when barrier " + rf"'{N}' fires in '{N}' — '{N}' must happen first", ("OBL002",)), + Branch("obl003_exit_definite", "BR-V4", PROTOCOL, + "OBL003: the method leaves while the obligation is open on every path", + rf"obligation '{N}' is not closed when {N} — the object is published in its " + rf"in-between state; close with '{N}' on every path \(a finally block covers " + rf"the throw paths\)", ("OBL003",)), + Branch("obl004_exit_maybe", "BR-V4", PROTOCOL, + "OBL004: the method leaves while it is open on some path", + rf"obligation '{N}' may not be closed \(open on some path\) when {N} — the " + rf"object is published in its in-between state; close with '{N}' on every " + rf"path \(a finally block covers the throw paths\)", ("OBL004",)), + Branch("obl005_dead_rule", "BR-V4", PROTOCOL, + "OBL005: a scoped protocol matched no reported method (advisory)", + rf"protocol '{N}' is scoped to \[.*\] but no reported method matches — " + rf"the rule is dead \(typo in scope\.methods\?\)", ("OBL005",)), ) # The `_consumed_suffix` / `[singleton registered at …]` tails ride inside the @@ -340,6 +358,11 @@ def _flow_local(bid: str, code: str, kind: str, what: str, tail: str, ("DI004", "DI005"), r".* \[singleton registered at .*?:\d+\]"), ("tail_registered_absent", "no registration tail (the primary IS the registration)", ("DI004", "DI005"), r"(?!.*\[singleton registered at ).*"), + ("tail_obl_exit_falls_off", "`the method falls off the end` — a leak off the end " + "has no exit site to name", ("OBL003", "OBL004"), + r".* when the method falls off the end — .*"), + ("tail_obl_exit_via", "`'' exits via return|throw` — the exit site is named", + ("OBL003", "OBL004"), r".* when '.*?' exits via (?:return|throw) — .*"), ) @@ -382,6 +405,14 @@ def matches(self, code: str, steps: list[list[object]]) -> bool: DI_CODES = ("DI001", "DI002", "DI003", "DI004", "DI005") +OBL_CODES = ("OBL001", "OBL002", "OBL003", "OBL004", "OBL005") +# The four protocol step labels (BR-P3). Written out rather than matched with +# `.*`, because which STEP a slice carries is the thing the family distinguishes +# — a two-step slice built from the wrong pair would otherwise count as covered. +_OBL_OPENED = rf"obligation '{N}' opens here \({N}\)" +_OBL_BARRIER = rf"barrier '{N}' fires while it is open" +_OBL_EXIT = rf"the method exits here via {N} while it is open" +_OBL_LATE_CLOSE = r"closed here — after the barrier has already fired" _CAPTOR = rf"singleton '{N}' \(captor\)" _VIA = rf"via '{N}'" _END = r"(?:captures scoped service|weakly captures scoped service|captures transient " @@ -422,12 +453,20 @@ def matches(self, code: str, steps: list[list[object]]) -> bool: ("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), + SliceFamily("protocol_flow_3", "OBL opened → barrier → late close", "flow", + OBL_CODES, (_OBL_OPENED, _OBL_BARRIER, _OBL_LATE_CLOSE)), + SliceFamily("protocol_flow_open_barrier", + "OBL opened → barrier, no close after it", "flow", OBL_CODES, + (_OBL_OPENED, _OBL_BARRIER)), + SliceFamily("protocol_flow_open_exit", "OBL opened → the exit it leaked through", + "flow", OBL_CODES, (_OBL_OPENED, _OBL_EXIT)), + SliceFamily("protocol_flow_open_only", + "OBL opened, and nothing else: a leak off the END anchors AT the open, " + "so its second step would repeat the first", "flow", OBL_CODES, + (_OBL_OPENED,)), + SliceFamily("protocol_flow_exit_only", + "OBL exit alone: the open carries no line (< 1) and its step is dropped", + "flow", OBL_CODES, (_OBL_EXIT,)), ) # The degradations BR-V5 names in prose. Counted as their own rows because a @@ -470,6 +509,9 @@ class Degradation: Degradation("flowlocal_flow_absent", "OWN001 on a local/pooled record: a single-point finding, no slice " "by design", "flow", ("OWN001",), ("disposable", "pooled buffer")), + Degradation("protocol_flow_absent", + "an OBL finding with no slice at all: the anchorless OBL005 by design, " + "and a leak whose only step has an unknown line", "flow", OBL_CODES), )