diff --git a/rust/crates/own-diagnostics/tests/ledger_replay.rs b/rust/crates/own-diagnostics/tests/ledger_replay.rs new file mode 100644 index 00000000..ecad8e16 --- /dev/null +++ b/rust/crates/own-diagnostics/tests/ledger_replay.rs @@ -0,0 +1,228 @@ +//! Zero-Python replay of the complete diagnostic-family ledger +//! (`tests/fixtures/diag_ledger.json`, authoritative via +//! `python tests/test_diag_ledger_fixtures.py --write`) — P-022 step 5a, #255, +//! PR 3 of 3. +//! +//! PR 1 pinned identity and PR 2 pinned rendering, both on curated shapes. This +//! makes the coverage total and self-policing: **every** code in +//! [`own_diagnostics::TITLES`] must have a case, so a family cannot be added +//! without a fixture. +//! +//! The three failure modes, each with its own test so a red build names the +//! actual problem: +//! +//! * **missing** — a `TITLES` code with no ledger case; +//! * **orphan** — a ledger case naming a code absent from `TITLES`; +//! * **divergence** — the rendered text disagrees with the reference. +//! +//! What this does NOT claim: that the analyzer emits a given code. That is step +//! 4's contract, pinned over the real `.own` corpus by `diag_parity.json`. The +//! ledger carries `analyzer_corpus` per code so the two claims stay separable — +//! rendering coverage is 47/47, analyzer-corpus coverage is not, and conflating +//! them would overstate the evidence. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::BTreeSet; + +use own_diagnostics::{title, Diagnostic, TITLES}; +use serde_json::Value; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/diag_ledger.json" +); + +const SCHEMA_VERSION: u64 = 1; + +fn load() -> Value { + let raw = std::fs::read_to_string(FIXTURE) + .expect("fixture missing — regenerate: python tests/test_diag_ledger_fixtures.py --write"); + let root: Value = serde_json::from_str(&raw).expect("diag_ledger.json parses"); + assert_eq!( + root.get("schema_version").and_then(Value::as_u64), + Some(SCHEMA_VERSION), + "fixture schema_version changed — a reviewed contract, not a passing reshape" + ); + root +} + +fn cases(root: &Value) -> &Vec { + root.get("cases") + .and_then(Value::as_array) + .expect("'cases' array") +} + +fn code_of(case: &Value) -> &str { + case.get("code") + .and_then(Value::as_str) + .expect("case 'code'") +} + +#[test] +fn every_title_code_has_a_ledger_case() { + let root = load(); + let covered: BTreeSet<&str> = cases(&root).iter().map(code_of).collect(); + let missing: Vec<&str> = TITLES + .iter() + .map(|(code, _)| *code) + .filter(|code| !covered.contains(code)) + .collect(); + assert!( + missing.is_empty(), + "{} diagnostic code(s) have no ledger case: {missing:?}. A new family must \ + ship with a fixture — regenerate: python tests/test_diag_ledger_fixtures.py --write", + missing.len() + ); +} + +#[test] +fn no_ledger_case_names_an_unknown_code() { + let root = load(); + let orphans: Vec<&str> = cases(&root) + .iter() + .map(code_of) + .filter(|code| title(code).is_none()) + .collect(); + assert!( + orphans.is_empty(), + "{} ledger case(s) name a code absent from TITLES: {orphans:?}. A removed or \ + renamed code must not leave its fixture behind", + orphans.len() + ); +} + +#[test] +fn ledger_case_count_matches_the_vocabulary() { + let root = load(); + let declared = root + .get("totals") + .and_then(|t| t.get("codes")) + .and_then(Value::as_u64) + .expect("'totals.codes'"); + assert_eq!( + usize::try_from(declared).expect("fits usize"), + cases(&root).len(), + "the ledger's own total disagrees with the number of cases it carries" + ); + assert_eq!( + cases(&root).len(), + TITLES.len(), + "the ledger and TITLES disagree on the size of the vocabulary" + ); +} + +#[test] +fn every_case_title_matches_the_ported_vocabulary() { + let root = load(); + for case in cases(&root) { + let code = code_of(case); + let expected = case + .get("title") + .and_then(Value::as_str) + .expect("case 'title'"); + assert_eq!( + title(code), + Some(expected), + "code {code:?}: the ported TITLES entry diverged from the reference" + ); + } +} + +/// The migration packet's counters, computed rather than asserted by hand. +/// +/// `Unexplained` is the one that must be zero; the others are reported so a +/// reviewer sees the shape of any difference instead of a bare boolean. +#[derive(Default, Debug)] +struct Counters { + python_only: usize, + rust_only: usize, + changed: usize, + unexplained: usize, +} + +#[test] +fn rendered_text_matches_the_reference_for_every_family() { + let root = load(); + let mut counters = Counters::default(); + let mut first_divergence: Option = None; + + let covered: BTreeSet<&str> = cases(&root).iter().map(code_of).collect(); + counters.python_only = TITLES + .iter() + .filter(|(code, _)| !covered.contains(code)) + .count(); + counters.rust_only = cases(&root) + .iter() + .map(code_of) + .filter(|code| title(code).is_none()) + .count(); + + for case in cases(&root) { + let code = code_of(case); + let path = case + .get("path") + .and_then(Value::as_str) + .expect("case 'path'"); + let diagnostic: Diagnostic = + serde_json::from_value(case.get("diagnostic").expect("'diagnostic'").clone()) + .unwrap_or_else(|e| panic!("code {code:?}: diagnostic does not load: {e}")); + let expected = case + .get("rendered") + .and_then(Value::as_str) + .expect("case 'rendered'"); + let produced = diagnostic.render(path); + if produced != expected { + counters.changed = counters.changed.saturating_add(1); + counters.unexplained = counters.unexplained.saturating_add(1); + if first_divergence.is_none() { + first_divergence = Some(format!( + "code {code:?}\n expected: {expected:?}\n produced: {produced:?}" + )); + } + } + } + + assert_eq!( + counters.unexplained, + 0, + "the migration packet requires Unexplained == 0. Counters: {counters:?}\n\ + first divergence:\n{}", + first_divergence.as_deref().unwrap_or("") + ); + assert_eq!(counters.python_only, 0, "counters: {counters:?}"); + assert_eq!(counters.rust_only, 0, "counters: {counters:?}"); + assert_eq!(counters.changed, 0, "counters: {counters:?}"); +} + +#[test] +fn analyzer_corpus_coverage_is_recorded_not_assumed() { + // Rendering coverage is total; analyzer-corpus coverage is not, and the + // ledger must keep saying so. If this ever reaches 47 it means the `.own` + // corpus genuinely grew — a real event worth a deliberate fixture update, + // not something to discover silently. + let root = load(); + let declared = root + .get("totals") + .and_then(|t| t.get("analyzer_corpus")) + .and_then(Value::as_u64) + .expect("'totals.analyzer_corpus'"); + let counted = cases(&root) + .iter() + .filter(|c| { + c.get("analyzer_corpus") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .count(); + assert_eq!( + usize::try_from(declared).expect("fits usize"), + counted, + "the ledger's analyzer_corpus total disagrees with its own per-code flags" + ); + assert!( + counted < cases(&root).len(), + "every code now claims analyzer-corpus coverage — verify that against \ + diag_parity.json before believing it" + ); +} diff --git a/tests/fixtures/diag_ledger.json b/tests/fixtures/diag_ledger.json new file mode 100644 index 00000000..83afc5e3 --- /dev/null +++ b/tests/fixtures/diag_ledger.json @@ -0,0 +1,1083 @@ +{ + "comment": "GENERATED by tests/test_diag_ledger_fixtures.py --write; do not edit. Python (ownlang) is authoritative. The COMPLETE diagnostic-family ledger (P-022 step 5a, issue #255, PR 3 of 3): every TITLES code has a case, so a new family cannot ship without a fixture. `analyzer_corpus` records whether the real .own sweep produces that code today -- rendering coverage is not the same claim as analyzer coverage, and this file does not conflate them.", + "schema_version": 1, + "totals": { + "codes": 47, + "by_family": { + "DI": 5, + "EFF": 1, + "OBL": 5, + "OWN": 36 + }, + "analyzer_corpus": 13 + }, + "cases": [ + { + "code": "DI001", + "family": "DI", + "analyzer_corpus": false, + "title": "captive dependency: a shorter-lived service is captured by a longer-lived one", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "DI001", + "message": "'di001_subject' -- captive dependency: a shorter-lived service is captured by a longer-lived one", + "line": 720, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:720: error: [DI001] 'di001_subject' -- captive dependency: a shorter-lived service is captured by a longer-lived one" + }, + { + "code": "DI002", + "family": "DI", + "analyzer_corpus": false, + "title": "singleton captures a scoped service (captive dependency)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "DI002", + "message": "'di002_subject' -- singleton captures a scoped service (captive dependency)", + "line": 458, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:458: error: [DI002] 'di002_subject' -- singleton captures a scoped service (captive dependency)\n note: related step at src/Ledger.cs:1" + }, + { + "code": "DI003", + "family": "DI", + "analyzer_corpus": false, + "title": "singleton captures a transient service (captive dependency)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "DI003", + "message": "'di003_subject' -- singleton captures a transient service (captive dependency)", + "line": 347, + "severity": "error", + "subject": "di003_subject#347", + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:347: error: [DI003] 'di003_subject' -- singleton captures a transient service (captive dependency)" + }, + { + "code": "DI004", + "family": "DI", + "analyzer_corpus": false, + "title": "scoped service resolved from the root provider (captured for the app lifetime)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "DI004", + "message": "'di004_subject' -- scoped service resolved from the root provider (captured for the app lifetime)", + "line": 319, + "severity": "error", + "subject": "di004_subject#319", + "resource_kind": "subscription token", + "evidence": [] + }, + "rendered": "src/Ledger.cs:319: error: [DI004] 'di004_subject' -- scoped service resolved from the root provider (captured for the app lifetime) [resource: subscription token]" + }, + { + "code": "DI005", + "family": "DI", + "analyzer_corpus": false, + "title": "disposable transient resolved from a long-lived scope (delayed disposal)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "DI005", + "message": "'di005_subject' -- disposable transient resolved from a long-lived scope (delayed disposal)", + "line": 886, + "severity": "warning", + "subject": null, + "resource_kind": "subscription token", + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:886: warning: [DI005] 'di005_subject' -- disposable transient resolved from a long-lived scope (delayed disposal) [resource: subscription token]\n note: related step at src/Ledger.cs:1" + }, + { + "code": "EFF001", + "family": "EFF", + "analyzer_corpus": false, + "title": "reactive effect re-runs on an unstable dependency identity (render-time IO storm)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "EFF001", + "message": "'eff001_subject' -- reactive effect re-runs on an unstable dependency identity (render-time IO storm)", + "line": 171, + "severity": "warning", + "subject": "eff001_subject#171", + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:171: warning: [EFF001] 'eff001_subject' -- reactive effect re-runs on an unstable dependency identity (render-time IO storm)" + }, + { + "code": "OBL001", + "family": "OBL", + "analyzer_corpus": false, + "title": "obligation still open when a barrier fires (open on every path)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OBL001", + "message": "'obl001_subject' -- obligation still open when a barrier fires (open on every path)", + "line": 869, + "severity": "error", + "subject": "obl001_subject#869", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:869: error: [OBL001] 'obl001_subject' -- obligation still open when a barrier fires (open on every path)\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OBL002", + "family": "OBL", + "analyzer_corpus": false, + "title": "obligation may still be open when a barrier fires (open on some path)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OBL002", + "message": "'obl002_subject' -- obligation may still be open when a barrier fires (open on some path)", + "line": 581, + "severity": "warning", + "subject": "obl002_subject#581", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:581: warning: [OBL002] 'obl002_subject' -- obligation may still be open when a barrier fires (open on some path)\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OBL003", + "family": "OBL", + "analyzer_corpus": false, + "title": "obligation not closed before the method exits (on every path)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OBL003", + "message": "'obl003_subject' -- obligation not closed before the method exits (on every path)", + "line": 514, + "severity": "error", + "subject": null, + "resource_kind": "subscription token", + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:514: error: [OBL003] 'obl003_subject' -- obligation not closed before the method exits (on every path) [resource: subscription token]\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OBL004", + "family": "OBL", + "analyzer_corpus": false, + "title": "obligation may not be closed before the method exits (on some path)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OBL004", + "message": "'obl004_subject' -- obligation may not be closed before the method exits (on some path)", + "line": 758, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:758: error: [OBL004] 'obl004_subject' -- obligation may not be closed before the method exits (on some path)\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OBL005", + "family": "OBL", + "analyzer_corpus": false, + "title": "protocol scope matched no reported method -- rule is dead (advisory)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OBL005", + "message": "'obl005_subject' -- protocol scope matched no reported method -- rule is dead (advisory)", + "line": 893, + "severity": "error", + "subject": "obl005_subject#893", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:893: error: [OBL005] 'obl005_subject' -- protocol scope matched no reported method -- rule is dead (advisory)\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OWN001", + "family": "OWN", + "analyzer_corpus": true, + "title": "owned resource not released on all paths (possible leak)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN001", + "message": "'own001_subject' -- owned resource not released on all paths (possible leak)", + "line": 564, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:564: error: [OWN001] 'own001_subject' -- owned resource not released on all paths (possible leak)" + }, + { + "code": "OWN002", + "family": "OWN", + "analyzer_corpus": true, + "title": "use after release", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN002", + "message": "'own002_subject' -- use after release", + "line": 140, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:140: error: [OWN002] 'own002_subject' -- use after release" + }, + { + "code": "OWN003", + "family": "OWN", + "analyzer_corpus": true, + "title": "double release", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN003", + "message": "'own003_subject' -- double release", + "line": 18, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:18: error: [OWN003] 'own003_subject' -- double release\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN004", + "family": "OWN", + "analyzer_corpus": false, + "title": "borrow escapes its scope", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN004", + "message": "'own004_subject' -- borrow escapes its scope", + "line": 786, + "severity": "warning", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:786: warning: [OWN004] 'own004_subject' -- borrow escapes its scope\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN005", + "family": "OWN", + "analyzer_corpus": true, + "title": "use after move", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN005", + "message": "'own005_subject' -- use after move", + "line": 286, + "severity": "warning", + "subject": null, + "resource_kind": "subscription token", + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:286: warning: [OWN005] 'own005_subject' -- use after move [resource: subscription token]\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN006", + "family": "OWN", + "analyzer_corpus": true, + "title": "mutable borrow while a shared borrow is live", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN006", + "message": "'own006_subject' -- mutable borrow while a shared borrow is live", + "line": 213, + "severity": "error", + "subject": "own006_subject#213", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:213: error: [OWN006] 'own006_subject' -- mutable borrow while a shared borrow is live\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OWN007", + "family": "OWN", + "analyzer_corpus": false, + "title": "move while borrowed", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN007", + "message": "'own007_subject' -- move while borrowed", + "line": 678, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:678: error: [OWN007] 'own007_subject' -- move while borrowed\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN008", + "family": "OWN", + "analyzer_corpus": true, + "title": "release while borrowed", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN008", + "message": "'own008_subject' -- release while borrowed", + "line": 22, + "severity": "error", + "subject": null, + "resource_kind": "subscription token", + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:22: error: [OWN008] 'own008_subject' -- release while borrowed [resource: subscription token]\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN009", + "family": "OWN", + "analyzer_corpus": true, + "title": "use after possible release (released on some path)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN009", + "message": "'own009_subject' -- use after possible release (released on some path)", + "line": 294, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:294: error: [OWN009] 'own009_subject' -- use after possible release (released on some path)\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN010", + "family": "OWN", + "analyzer_corpus": false, + "title": "use after possible move (moved on some path)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN010", + "message": "'own010_subject' -- use after possible move (moved on some path)", + "line": 112, + "severity": "error", + "subject": null, + "resource_kind": "subscription token", + "evidence": [] + }, + "rendered": "src/Ledger.cs:112: error: [OWN010] 'own010_subject' -- use after possible move (moved on some path) [resource: subscription token]" + }, + { + "code": "OWN011", + "family": "OWN", + "analyzer_corpus": false, + "title": "mutable borrow while another mutable borrow is live", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN011", + "message": "'own011_subject' -- mutable borrow while another mutable borrow is live", + "line": 194, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:194: error: [OWN011] 'own011_subject' -- mutable borrow while another mutable borrow is live\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN012", + "family": "OWN", + "analyzer_corpus": false, + "title": "shared borrow while a mutable borrow is live", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN012", + "message": "'own012_subject' -- shared borrow while a mutable borrow is live", + "line": 140, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:140: error: [OWN012] 'own012_subject' -- shared borrow while a mutable borrow is live" + }, + { + "code": "OWN013", + "family": "OWN", + "analyzer_corpus": false, + "title": "owner accessed while it is mutably borrowed", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN013", + "message": "'own013_subject' -- owner accessed while it is mutably borrowed", + "line": 132, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:132: error: [OWN013] 'own013_subject' -- owner accessed while it is mutably borrowed" + }, + { + "code": "OWN014", + "family": "OWN", + "analyzer_corpus": true, + "title": "value escapes to a longer-lived region (lifetime promotion)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN014", + "message": "'own014_subject' -- value escapes to a longer-lived region (lifetime promotion)", + "line": 290, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:290: error: [OWN014] 'own014_subject' -- value escapes to a longer-lived region (lifetime promotion)\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN015", + "family": "OWN", + "analyzer_corpus": true, + "title": "stack-backed buffer cannot escape the current function", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN015", + "message": "'own015_subject' -- stack-backed buffer cannot escape the current function", + "line": 5, + "severity": "error", + "subject": "own015_subject#5", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:5: error: [OWN015] 'own015_subject' -- stack-backed buffer cannot escape the current function\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OWN016", + "family": "OWN", + "analyzer_corpus": false, + "title": "stack-backed buffer moved to a longer-lived owner", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN016", + "message": "'own016_subject' -- stack-backed buffer moved to a longer-lived owner", + "line": 894, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:894: error: [OWN016] 'own016_subject' -- stack-backed buffer moved to a longer-lived owner\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN017", + "family": "OWN", + "analyzer_corpus": false, + "title": "movable buffer escape is not supported by code generation (PoC limitation)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN017", + "message": "'own017_subject' -- movable buffer escape is not supported by code generation (PoC limitation)", + "line": 657, + "severity": "error", + "subject": "own017_subject#657", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:657: error: [OWN017] 'own017_subject' -- movable buffer escape is not supported by code generation (PoC limitation)\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OWN018", + "family": "OWN", + "analyzer_corpus": false, + "title": "buffer size must be an integer", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN018", + "message": "'own018_subject' -- buffer size must be an integer", + "line": 827, + "severity": "error", + "subject": "own018_subject#827", + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:827: error: [OWN018] 'own018_subject' -- buffer size must be an integer" + }, + { + "code": "OWN019", + "family": "OWN", + "analyzer_corpus": true, + "title": "inline capacity too large for a stack-backed policy", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN019", + "message": "'own019_subject' -- inline capacity too large for a stack-backed policy", + "line": 182, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:182: error: [OWN019] 'own019_subject' -- inline capacity too large for a stack-backed policy\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN020", + "family": "OWN", + "analyzer_corpus": true, + "title": "unsupported construct (out of scope for the MVP)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN020", + "message": "'own020_subject' -- unsupported construct (out of scope for the MVP)", + "line": 881, + "severity": "warning", + "subject": "own020_subject#881", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:881: warning: [OWN020] 'own020_subject' -- unsupported construct (out of scope for the MVP)\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OWN021", + "family": "OWN", + "analyzer_corpus": false, + "title": "stack allocation requires a statically known bound", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN021", + "message": "'own021_subject' -- stack allocation requires a statically known bound", + "line": 600, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:600: error: [OWN021] 'own021_subject' -- stack allocation requires a statically known bound" + }, + { + "code": "OWN023", + "family": "OWN", + "analyzer_corpus": false, + "title": "scratch fallback forbidden but the size may exceed the inline limit", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN023", + "message": "'own023_subject' -- scratch fallback forbidden but the size may exceed the inline limit", + "line": 231, + "severity": "warning", + "subject": "own023_subject#231", + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:231: warning: [OWN023] 'own023_subject' -- scratch fallback forbidden but the size may exceed the inline limit" + }, + { + "code": "OWN024", + "family": "OWN", + "analyzer_corpus": false, + "title": "sensitive buffer is not cleared on release", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN024", + "message": "'own024_subject' -- sensitive buffer is not cleared on release", + "line": 866, + "severity": "warning", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:866: warning: [OWN024] 'own024_subject' -- sensitive buffer is not cleared on release\n note: related step at src/Ledger.cs:1" + }, + { + "code": "OWN025", + "family": "OWN", + "analyzer_corpus": true, + "title": "full-length view of a pooled buffer reaches past its logical length", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN025", + "message": "'own025_subject' -- full-length view of a pooled buffer reaches past its logical length", + "line": 303, + "severity": "error", + "subject": "own025_subject#303", + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:303: error: [OWN025] 'own025_subject' -- full-length view of a pooled buffer reaches past its logical length" + }, + { + "code": "OWN030", + "family": "OWN", + "analyzer_corpus": false, + "title": "undefined name", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN030", + "message": "'own030_subject' -- undefined name", + "line": 627, + "severity": "error", + "subject": "own030_subject#627", + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:627: error: [OWN030] 'own030_subject' -- undefined name" + }, + { + "code": "OWN031", + "family": "OWN", + "analyzer_corpus": false, + "title": "name already defined in this scope", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN031", + "message": "'own031_subject' -- name already defined in this scope", + "line": 677, + "severity": "error", + "subject": "own031_subject#677", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:677: error: [OWN031] 'own031_subject' -- name already defined in this scope\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OWN032", + "family": "OWN", + "analyzer_corpus": false, + "title": "owned resource copied without 'move'", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN032", + "message": "'own032_subject' -- owned resource copied without 'move'", + "line": 465, + "severity": "error", + "subject": "own032_subject#465", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:465: error: [OWN032] 'own032_subject' -- owned resource copied without 'move'\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OWN033", + "family": "OWN", + "analyzer_corpus": false, + "title": "function must return a value on all paths", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN033", + "message": "'own033_subject' -- function must return a value on all paths", + "line": 96, + "severity": "warning", + "subject": null, + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:96: warning: [OWN033] 'own033_subject' -- function must return a value on all paths" + }, + { + "code": "OWN034", + "family": "OWN", + "analyzer_corpus": false, + "title": "operation requires an owned resource", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN034", + "message": "'own034_subject' -- operation requires an owned resource", + "line": 203, + "severity": "error", + "subject": "own034_subject#203", + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:203: error: [OWN034] 'own034_subject' -- operation requires an owned resource" + }, + { + "code": "OWN035", + "family": "OWN", + "analyzer_corpus": false, + "title": "return type mismatch", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN035", + "message": "'own035_subject' -- return type mismatch", + "line": 509, + "severity": "error", + "subject": "own035_subject#509", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:509: error: [OWN035] 'own035_subject' -- return type mismatch\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OWN036", + "family": "OWN", + "analyzer_corpus": false, + "title": "cyclic lifetime ordering", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN036", + "message": "'own036_subject' -- cyclic lifetime ordering", + "line": 235, + "severity": "error", + "subject": "own036_subject#235", + "resource_kind": "subscription token", + "evidence": [] + }, + "rendered": "src/Ledger.cs:235: error: [OWN036] 'own036_subject' -- cyclic lifetime ordering [resource: subscription token]" + }, + { + "code": "OWN040", + "family": "OWN", + "analyzer_corpus": true, + "title": "call to an undeclared function (unknown calls are forbidden)", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN040", + "message": "'own040_subject' -- call to an undeclared function (unknown calls are forbidden)", + "line": 244, + "severity": "error", + "subject": null, + "resource_kind": "subscription token", + "evidence": [] + }, + "rendered": "src/Ledger.cs:244: error: [OWN040] 'own040_subject' -- call to an undeclared function (unknown calls are forbidden) [resource: subscription token]" + }, + { + "code": "OWN041", + "family": "OWN", + "analyzer_corpus": false, + "title": "call argument mismatch", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN041", + "message": "'own041_subject' -- call argument mismatch", + "line": 373, + "severity": "error", + "subject": "own041_subject#373", + "resource_kind": "subscription token", + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:373: error: [OWN041] 'own041_subject' -- call argument mismatch [resource: subscription token]\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OWN050", + "family": "OWN", + "analyzer_corpus": false, + "title": "declaring type unresolved -- leakage analysis skipped", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN050", + "message": "'own050_subject' -- declaring type unresolved -- leakage analysis skipped", + "line": 612, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [] + }, + "rendered": "src/Ledger.cs:612: error: [OWN050] 'own050_subject' -- declaring type unresolved -- leakage analysis skipped" + }, + { + "code": "OWN051", + "family": "OWN", + "analyzer_corpus": false, + "title": "ownership transfer unverified -- local not checked past this call", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN051", + "message": "'own051_subject' -- ownership transfer unverified -- local not checked past this call", + "line": 701, + "severity": "warning", + "subject": "own051_subject#701", + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "acquired here", + "file": null, + "role": "acquired" + }, + { + "line": 2, + "label": "escapes here", + "file": "src/Other.cs", + "role": "escaped" + } + ] + }, + "rendered": "src/Ledger.cs:701: warning: [OWN051] 'own051_subject' -- ownership transfer unverified -- local not checked past this call\n note: acquired here at src/Ledger.cs:1\n note: escapes here at src/Other.cs:2" + }, + { + "code": "OWN052", + "family": "OWN", + "analyzer_corpus": false, + "title": "interprocedural summary inference failed -- method summaries skipped", + "path": "src/Ledger.cs", + "diagnostic": { + "code": "OWN052", + "message": "'own052_subject' -- interprocedural summary inference failed -- method summaries skipped", + "line": 882, + "severity": "error", + "subject": null, + "resource_kind": null, + "evidence": [ + { + "line": 1, + "label": "related step", + "file": null, + "role": "related" + } + ] + }, + "rendered": "src/Ledger.cs:882: error: [OWN052] 'own052_subject' -- interprocedural summary inference failed -- method summaries skipped\n note: related step at src/Ledger.cs:1" + } + ] +} diff --git a/tests/test_diag_ledger_fixtures.py b/tests/test_diag_ledger_fixtures.py new file mode 100644 index 00000000..aa941be0 --- /dev/null +++ b/tests/test_diag_ledger_fixtures.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Complete diagnostic-family ledger (P-022 step 5a, issue #255) — Python side. + +PR 3 of three, and the one that closes #255. PR 1 pinned structural identity, +PR 2 pinned canonical rendering and emission order — both on curated shapes. +This slice makes the coverage **total and self-policing**: every code in +`ownlang.diagnostics.TITLES` gets a fixture case, and a new family cannot be +added without one. + +## What the ledger proves, and what it deliberately does not + +A case here constructs a real `Diagnostic` and freezes the reference's rendering +of it. That pins the **diagnostic-layer** contract for that code — message, +severity, subject, resource kind, ordered Evidence, and the exact text — which +is what #255 is about. + +It does **not** claim the analyzer fires that code on any particular input. +That is step 4's contract (#214), already pinned by `tests/fixtures/diag_parity.json` +over the real `.own` corpus. The two are different questions and the ledger says +which is which per code: `analyzer_corpus: true` means the `.own` sweep actually +produces it today. + +That distinction is the honest part. The `.own` sweep exercises **13 of 47** +codes; the rest are either bridge-only families (DI/EFF/OBL come from +`ownlang.ownir`, not the `.own` core) or core codes no corpus input happens to +reach. Silently rendering all 47 and calling it "full parity" would overstate +the evidence, so the coverage flag is carried per code and summarised. + +## The three failure modes it closes + +* **missing** — a code in `TITLES` with no case. The replay fails naming it, so + adding a diagnostic without a fixture is a red build. +* **orphan** — a case whose code is not in `TITLES`. Catches a removed or + renamed code leaving a fixture behind. +* **stale** — the generated file differs from what the current reference + produces (the standing `--write` discipline, as in the other two slices). + +Run: python tests/test_diag_ledger_fixtures.py (verify) + python tests/test_diag_ledger_fixtures.py --write (regenerate) + python tests/run_tests.py (runs it as part of the suite) +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.diagnostics import TITLES, Diagnostic, Evidence, Severity + +FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "diag_ledger.json") +CORPUS_PARITY = os.path.join(os.path.dirname(__file__), "fixtures", "diag_parity.json") + +SCHEMA_VERSION = 1 + +# The anchor path every ledger case renders against; a fixed value keeps the +# golden diffable when a case is added in the middle. +_PATH = "src/Ledger.cs" + + +def _corpus_codes() -> set[str]: + """Codes the real `.own` corpus sweep actually produces today. + + Read from the step-4 fixture rather than restated, so this flag cannot drift + from the analyzer's true reach.""" + with open(CORPUS_PARITY, encoding="utf-8") as f: + data = json.load(f) + return {code for case in data["cases"] for _line, code in case["diags"]} + + +def _family(code: str) -> str: + match = re.match(r"[A-Z]+", code) + return match.group(0) if match else "?" + + +def _seed(code: str) -> int: + """A stable per-code value, independent of the code's position in TITLES. + + Deliberately NOT the sorted index. Adding a family is the single event this + ledger exists to make visible, and an index-derived shape sabotages exactly + that: inserting one code shifts every later index, so `DI006` would rewrite + 42 of 47 existing records (measured). A one-record change is reviewable at a + glance; 42 records of churn hide whether the renderer also moved. + + SHA-256 rather than `hash()`, whose string hashing is randomised per process + and would make the golden unreproducible.""" + digest = hashlib.sha256(code.encode("utf-8")).hexdigest() + return int(digest[:8], 16) + + +def _shape_for(code: str) -> Diagnostic: + """A representative `Diagnostic` for `code`. + + The message is derived from the reference's own TITLE (so it is meaningful + and cannot drift from the vocabulary), with a quoted subject spliced in so + the caret heuristic has something to find. Optional fields rotate by the + code's own [`_seed`], so the ledger exercises every shape across the family + set rather than 47 copies of one -- and each code's shape is fixed for good, + whatever else joins the vocabulary. + """ + title = TITLES[code] + message = f"'{code.lower()}_subject' -- {title}" + seed = _seed(code) + line = seed % 900 + 1 + kwargs: dict[str, object] = {} + if seed % 2 == 0: + kwargs["subject"] = f"{code.lower()}_subject#{line}" + if seed % 3 == 0: + kwargs["resource_kind"] = "subscription token" + if seed % 5 == 0: + kwargs["severity"] = Severity.WARNING + if seed % 4 == 0: + kwargs["evidence"] = ( + Evidence(line=1, label="acquired here", role="acquired"), + Evidence(line=2, label="escapes here", role="escaped", + file="src/Other.cs"), + ) + elif seed % 4 == 1: + kwargs["evidence"] = (Evidence(line=1, label="related step"),) + return Diagnostic(code=code, message=message, line=line, **kwargs) # type: ignore[arg-type] + + +def _evidence_json(ev: Evidence) -> dict[str, object]: + return {"line": ev.line, "label": ev.label, "file": ev.file, "role": ev.role} + + +def _diagnostic_json(d: Diagnostic) -> dict[str, object]: + return { + "code": d.code, + "message": d.message, + "line": d.line, + "severity": d.severity.value, + "subject": d.subject, + "resource_kind": d.resource_kind, + "evidence": [_evidence_json(e) for e in d.evidence], + } + + +def build() -> dict[str, object]: + corpus = _corpus_codes() + cases: list[dict[str, object]] = [] + for code in sorted(TITLES): + diag = _shape_for(code) + cases.append({ + "code": code, + "family": _family(code), + # True when the real `.own` corpus sweep produces this code today + # (read from diag_parity.json, never restated). + "analyzer_corpus": code in corpus, + "title": TITLES[code], + "path": _PATH, + "diagnostic": _diagnostic_json(diag), + "rendered": diag.render(_PATH), + }) + + families: dict[str, int] = {} + for case in cases: + family = str(case["family"]) + families[family] = families.get(family, 0) + 1 + + return { + "comment": ( + "GENERATED by tests/test_diag_ledger_fixtures.py --write; do not edit. " + "Python (ownlang) is authoritative. The COMPLETE diagnostic-family ledger " + "(P-022 step 5a, issue #255, PR 3 of 3): every TITLES code has a case, so a " + "new family cannot ship without a fixture. `analyzer_corpus` records whether " + "the real .own sweep produces that code today -- rendering coverage is not " + "the same claim as analyzer coverage, and this file does not conflate them." + ), + "schema_version": SCHEMA_VERSION, + "totals": { + "codes": len(cases), + "by_family": dict(sorted(families.items())), + "analyzer_corpus": sum(1 for c in cases if c["analyzer_corpus"]), + }, + "cases": cases, + } + + +def _render_json(data: dict[str, object]) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + +def run() -> int: + expected = _render_json(build()) + if not os.path.exists(FIXTURE): + print(f"FAIL: {FIXTURE} missing; regenerate with " + f"'python tests/test_diag_ledger_fixtures.py --write'") + return 1 + with open(FIXTURE, encoding="utf-8") as f: + actual = f.read() + if actual != expected: + print(f"FAIL: {FIXTURE} is stale (a code, title or renderer changed); " + f"regenerate with 'python tests/test_diag_ledger_fixtures.py --write' " + f"and re-run the Rust side (cd rust && cargo test -p own-diagnostics)") + return 1 + + data = json.loads(actual) + if data.get("schema_version") != SCHEMA_VERSION: + print(f"FAIL: {FIXTURE} schema_version " + f"{data.get('schema_version')!r} != {SCHEMA_VERSION}") + return 1 + + # The two failure modes the LEDGER exists for, asserted on the Python side + # too so a regeneration cannot quietly paper over either. + covered = {str(case["code"]) for case in data["cases"]} + missing = sorted(set(TITLES) - covered) + orphans = sorted(covered - set(TITLES)) + if missing: + print(f"FAIL: {len(missing)} code(s) in TITLES have no ledger case: {missing}") + return 1 + if orphans: + print(f"FAIL: {len(orphans)} ledger case(s) name a code absent from TITLES: " + f"{orphans}") + return 1 + + churn = _insertion_churn() + if churn: + print(f"FAIL: inserting one code rewrote {churn} existing ledger record(s). " + f"Shapes must derive from the code itself, never from its position in " + f"the sorted vocabulary — adding a family has to be a ONE-record diff, " + f"or a real renderer change hides in the churn") + return 1 + + totals = data["totals"] + print(f"diagnostic ledger OK: {totals['codes']}/{len(TITLES)} codes covered " + f"({totals['by_family']}), " + f"{totals['analyzer_corpus']} of them also produced by the .own corpus sweep; " + f"a new code rewrites {churn} existing record(s)") + return 0 + + +def _insertion_churn() -> int: + """How many EXISTING records change when one new code joins the vocabulary. + + Must be zero. This is the property that makes the ledger readable: the whole + point is that adding a diagnostic family shows up as a single new record a + reviewer can check, not as a wall of unrelated edits. An index-derived shape + scored 42 of 47 here before the seed was made position-independent. + + A probe code is inserted mid-vocabulary (so it shifts sorted positions) and + removed again in a `finally`, so the live TITLES the rest of the suite sees + is untouched.""" + probe = "DI999" + if probe in TITLES: # pragma: no cover - defensive + return 0 + before = {case["code"]: case for case in build()["cases"]} + TITLES[probe] = "ledger insertion probe (not a real diagnostic)" + try: + after = {case["code"]: case for case in build()["cases"]} + finally: + del TITLES[probe] + shared = set(before) & set(after) - {probe} + return sum(1 for code in shared if before[code] != after[code]) + + +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_json(build())) + print(f"wrote {FIXTURE}") + raise SystemExit(0) + raise SystemExit(run())