diff --git a/docs/notes/p022-bridge-verdict-checkpoint4.md b/docs/notes/p022-bridge-verdict-checkpoint4.md new file mode 100644 index 00000000..168d72e1 --- /dev/null +++ b/docs/notes/p022-bridge-verdict-checkpoint4.md @@ -0,0 +1,253 @@ +# P-022 step 6b (#259) — checkpoint 4: analysis wiring + +> Status: **checkpoint-4 deliverable** — the bridge feeds real `OwnIR` facts +> through the ported analyses and maps their verdicts back to C# anchors, at +> the checkpoint-4 comparison surface (identity, anchor, kind, tiering). +> Checkpoint 5 (messages, evidence, rendered surfaces) is unblocked and +> compares against the same goldens. Written from the tree at the commit that +> landed it; the numbers below are the ones the suites assert. + +## What landed + +- **`own_bridge::check_facts(&OwnIr) -> Result, BridgeError>`** — + `ownlang/ownir.py::check_facts` as spec/Bridge.md §5 writes it, in order: + lower (the cp2 `lower_full`, now also returning the Python-shaped handle + records with `column`, the OWN051 tuples and the solve-failure reason) → + the Layer 2 document rebuilt as the core AST (`ast.rs`) → + `own_analysis::check_module` (buffer policy, lifetime, resolver, ownership) + → ERROR-only, the closed BR-V2 skip list, `subject` → handle or the + reference's map-or-raise refusal (BR-V3) → anchors per BR-V5 (the record's + line; OWN025 at the view site with `column: None`; DI004/DI005 at the + finder-selected call/store site) → kind and tiering per BR-V4/V6 → DI + (`services[]` through BR-P1's coercions into the five `own-analysis` + finders, in the bridge's append order) → effects (`effects[]`, BR-P2 + skip-not-coerce, `find_effect_storms`) → OWN050 → OWN051 → OWN052 → dedup + (BR-V7) → the stable `(file, line, column or 0, code)` sort (BR-V8). +- **Layer 2 → core AST** rather than a second lowering: the projection + `ownlang/lowered.py` drops only the declaration lines `to_module` fixes at + `0`, so the AST built from the 27/27 byte-exact document is the reference's + node for node, and a lowering defect stays visible at the Layer 2 seam + instead of hiding behind a verdict. +- **The one core change: the verdict `subject`.** `own-analysis` emitted + `(code, line)` only; the bridge maps through `subject` (`name#line`) and + nothing else, so `Emit::push_at` now stamps the symbol's `origin` exactly + where `analysis.py` passes `subject=sym.origin` (state problems, OWN001 via + the RID's minting symbol, release, overspan, the return path, the buffer + escape codes) and `lifetimes.py` stamps `source#line` on OWN014 — and + nowhere else (the loan/permission codes stay subject-less, as in Python). + Pinned through `check_module` in `own-analysis/tests/subject.rs`. + Not stamped, deliberately: `resource_kind`, messages and evidence on the + `.own` path are the core's own later contract (own-cli), not this + checkpoint's, and nothing here asserts them. +- **Layer 3 fixture family** (spec/Bridge.md §6): `ownlang/verdicts.py` + (`VERDICTS_VERSION = 1`, every `Finding` member in declaration order, a + refusal as `{"error": …}`), `tests/test_verdict_fixtures.py` (verify / + `--write`, ledger == swept corpora + synthetic == goldens, orphan/stale/ + missing red, determinism), `tests/fixtures/verdicts/manifest.json`, and the + Rust replay `own-bridge/tests/verdicts.rs`. All cases go through the + **tolerant door** (`check_facts` on the loaded document) on both sides. +- DAG: `own-bridge` → `own-syntax`, `own-cfg`, `own-analysis`, + `own-diagnostics` added to the allowed set; a named test now asserts no + core crate depends on the bridge. + +## Census + +| | | +|---|---| +| goldens (Python's truth, complete) | **77** — 22 `ownir` + 27 `lowered` + 9 `summaries` swept, **19** synthetic | +| replayed by Rust at the cp4 surface | **69** — 5 refusals, **127** findings | +| declared exclusions (executable) | **8** — 2 protocol documents, 4 coordinate-boundary controls, 2 OD-1 door controls | +| Python-only / Rust-only / Changed / Ordering-only / Unexplained, over the replayed set | **0 / 0 / 0 / 0 / 0** | + +The **compared members** at cp4: `file, line, column, code, component, event, +handler, kind, advisory, severity, ignore_reason`. Not compared yet: +`message`, `related`, `flow` — the goldens carry them, cp5 compares them +without regenerating a golden. + +### The unmeasured set is named, not hidden + +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 +holding is a red build demanding promotion. + +1. **Obligation protocols (2).** `ownlang/obligations.py` has no + `own-analysis` port. A document that declares a protocol is **refused** + by `check_facts`, never given a verdict list with a family missing — + `protocol_isloaded_clean` would otherwise have "matched" vacuously. + #259's checkpoint list never names the protocol analysis; its final + acceptance ("full test-family inventory from #258") does. Recorded as + **its own checkpoint (P-022 row 4b)**, deliberately not folded into cp5: + cp5 is messages, evidence and rendering, and a whole analysis family on + top would make the last checkpoint a bag. 4b does not block cp5; #259's + final acceptance needs it. +2. **The `u32` coordinate domain (4).** The core's line is a `u32`; the + strict door admits every signed 64-bit coordinate (`spec/OwnIR.md` §4.2) + and the tolerant door anything `_as_int` passes. A coordinate outside + `0..=u32::MAX` on a lowered node, a DI registration line or an effect line + is **refused, never clamped** (`ast::core_line`); a site or binding line + whose only reader guards on `>= 1` folds a negative value to `0`, which + is exact on every path (`verdict_di_duplicate_sites_last_wins` pins it). + The reference analyzes these documents (OWN001 at `B.cs:-1`, DI001 at + `reg.cs:-5`, …). This is the cp1 pattern again: a divergence family + outside the measured set, recorded so a decision can be taken — a + Python-first bound in §4.2, or a wider core line type — rather than + "closed by widening Rust" or reported as parity. The owner's stated + direction on review: **Python-first tightening** — define the valid + coordinate domain normatively (a negative source line is meaningless), + teach the reference to reject it, then remove the exclusion; as its own + contract change with parity evidence, never by declaring the reference + wrong because the port is `u32`. +3. **OD-1, measured (2).** The Rust tolerant entry is the typed `OwnIr` + constructor, so BR-D2's skip-not-coerce (`deps: "a"`) and the finders' + unknown-lifetime tolerance are unreachable through it: the reference + reports the sibling finding, the constructor refuses the document. The + bridge-side port of both rules exists and is pinned at the raw-document + level (`verdict::tests`), because that is the only level the production + surface can reach them from today. + +One **comparison** boundary on refusals: the map-or-raise text interpolates +the core diagnostic's `message`, and this core's messages are still titles +(`undefined name` vs the reference's `undefined name 'loc_0'`), so the three +`hoist_neg_*` refusals are compared up to their `message=` member — on both +sides, by the same function. The lowering-time refusals are byte-exact. + +### The `subject` tail + +The core change adds data to a diagnostic; whether it changes any +**serialized** surface was checked rather than assumed. On the Rust side +`render.rs` states it does not consult `subject`, `sarif.rs` never reads it, +and its only carrier is `LocatedDiagnostic`/`DiagIdentity` — an in-memory +comparison key, not an output. On the reference, `subject` is read by the +bridge and by `report.py` (the `.ownreport.json` buffer report, struck from +the port in #256). No Rust output surface serializes it, so "behavior +changes: none" holds; cp5 re-checks this when the bridge's render/SARIF +paths land. + +### The dedup key, minus `message` + +BR-V7's key is `(file, line, column, code, component, event, handler, +message, kind, advisory, severity, ignore_reason)`; cp4 carries every member +but `message`. On the reference's own outputs that is exact: every message is +a function of the handle record and the code (the flow-local wordings key on +`code`/`pool`/`ever_released`, the token wordings on the record, and the +same-handle same-code duplicates BR-V7 exists for are byte-identical), so two +findings equal on the carried members are equal on the message. The corpus +measures it (the two-exit and nested-throw leaks fold to one finding on both +sides); cp5 adds the member. + +## Mutation campaign + +Per the P-022 discipline (rule 2: a test is evidence only once its mutation +fails through the production surface; rule 3: no fail-fast — every catching +layer is recorded). Each mutation is applied to a copy of the file and +restored from that copy, never by `git checkout` (the lesson from cp1's +third round); `M00` is the harness-honesty control (no mutation must report +zero failures); a compile error is reported as such, never as "caught". + +**Round 1 (24 mutations) surfaced three real gaps and one bad mutation**, +all fixed before round 2: + +- M01 survived — nothing in the corpus produced an OWN033/034/035/041, so + the *closed* BR-V2 list had no control per member. + → `verdict_skip_list_artifacts` (partial return, bare return beside a + value return, a borrow handed to a consume position, an arity mismatch; + OWN040 is unreachable by construction). +- M04/M06 survived — `column` and `event` were redundant in the dedup key on + that corpus (`handler` and the distinct names kept the pairs apart). + → a same-name rebind on one line differing only in column; one record + pair per key member (event, handler, component, kind, severity, + ignore_reason) differing in exactly that member. +- M10 was not a mutation: `owned_here.is_empty() && …` kept the gate for the + empty case it was meant to drop. Rewritten as `false && …`. +- The harness attributed catchers to the wrong target: cargo's `Running` + lines are on stderr, test results on stdout, and capturing them + separately loses the interleaving. Fixed by merging the streams. + +**Round 2: 30 mutations, 29 caught, 1 declared survivor — closed on review +(round 3): 30/30.** M19 survived because BR-V1's ERROR-only rule guards a +state the corpus cannot produce (no facts producer reaches the one core +pass that grades below ERROR). Leaving a normative rule permanently +unprovable was the wrong resting place: the filter is now a pure predicate +(`is_mapped`, BR-V1 + BR-V2 in one place) and a synthetic WARNING is driven +through `map_core` itself — same diagnostic, same handle, ERROR → one +finding, WARNING → none — so the mutation is caught at the unit level, with +the corpus still unable to reach it (recorded as such below). + +| id | mutation | caught by | +|---|---|---| +| M01 | BR-V2 skip list: OWN033 no longer skipped | verdict replay | +| M02 | BR-V5 flow-local anchored at the core's line, not the acquire record | verdict replay | +| M03 | BR-V5 OWN025 anchored at the acquire, not the view site | verdict replay | +| M04 | BR-V7 dedup key drops `column` | verdict replay | +| M05 | BR-V8 sort key drops `column` | verdict replay | +| M06 | BR-V7 dedup key drops `event` | verdict replay | +| M07 | DI004/DI005 duplicate site: last-wins → first-wins (`own-analysis`) | `fact_parity::di_fact_parity` **and** verdict replay | +| M08 | BR-V6 DI001 graded `warning` | verdict replay | +| M09 | BR-D2 effect `deps` coerced instead of skipped | `verdict::tests::malformed_effect_entries_are_skipped_not_coerced` (raw-document level — the only reachable one, see OD-1) | +| M10 | BR-L8 OWN051 owned-local gate dropped | verdict replay | +| M11 | BR-M1 OWN052 never minted | verdict replay | +| M12 | BR-V3 OWN001 emitted without a subject (`own-analysis`) | `subject.rs` ×3 **and** verdict replay | +| M13 | BR-V3 OWN014 emitted without a subject (`own-analysis`) | `subject.rs` **and** verdict replay | +| M14 | protocol documents no longer refused | verdict replay (the executable exclusion ledger) | +| M15 | `core_line` clamps instead of refusing | verdict replay (the executable exclusion ledger) | +| M16 | BR-V6 source tiering inverted | verdict replay | +| M17 | BR-V6 an empty `ignore_reason` suppresses | verdict replay | +| M18 | BR-V7 dedup removed | verdict replay | +| M19 | BR-V1 ERROR-only half of the mapping predicate removed | `verdict::tests::only_error_severity_core_verdicts_are_mapped` (synthetic WARNING through `map_core`; the corpus cannot reach it — no facts producer feeds a sub-error core verdict today) | +| M20 | `_as_col` accepts `0` | verdict replay | +| M21 | a negative DI site line folds to `1`, not `0` | verdict replay | +| M22 | OWN050 never minted | verdict replay | +| M23 | BR-V3 handle read from the wrong subject separator | verdict replay | +| M24 | OWN051 anchored at `0`, not the call line | verdict replay | +| M25 | BR-V7 dedup key drops `handler` | verdict replay | +| M26 | BR-V7 dedup key drops `component` | verdict replay | +| M27 | BR-V7 dedup key drops `kind` | verdict replay | +| M28 | BR-V7 dedup key drops `severity` | verdict replay | +| M29 | BR-V7 dedup key drops `ignore_reason` | verdict replay | +| M30 | DI findings anchored at `0`, not the finder's anchor | `verdict::tests::di_coercions_match_the_reference` **and** verdict replay | + +Two catching layers exist where the analysis owns the rule (M07, M12, M13, +M30) and one where only the bridge does — the same shape as cp1's ledger: +the bridge replay is the outer catcher, and a rule with a single catcher is a +rule with a single control. Two rules (M09, M19) are catchable only at the +unit level because the production entry cannot reach them; both say so in +the test. + +## What checkpoint 5 needs + +The comparison matrix over the same frozen goldens — cp5 turns every +"deferred" row into "prove" and keeps every "proven" row as a regression +guard; **no golden is regenerated beside the cp5 implementation**, because +"implementation disagreed with the golden → regenerate → agreement" is the +one move this family exists to make impossible: + +| `Finding` surface | cp4 | cp5 | +|---|---|---| +| identity (`file`, `code`, `component`, `event`, `handler`) | proven | regression | +| anchor (`line`, `column`) | proven | regression | +| `kind` | proven | regression | +| tiering (`advisory`, `severity`, `ignore_reason`) | proven | regression | +| ordering and dedup | proven | regression | +| `message` | deferred (carried) | prove | +| `related` | deferred (carried) | prove | +| `flow` (evidence slices) | deferred (carried) | prove | +| rendered diagnostic (`render*`) | deferred | prove | +| SARIF projection (`build_sarif`, bridge path) | deferred | prove | + +And the wording discipline that comes with it: what cp4 proved is +"127 verdicts × the cp4 comparison projection", never "verdict parity +complete" — the shortcut a roadmap status is made of. + +Concretely: + +- the message matrix (BR-V4) on the bridge findings, and the core diagnostic + messages behind the map-or-raise text; +- the `related`/`flow` evidence slices (DI paths via the registration map, + the DI004/DI005 registration `related`, the effect re-run → mint slice, + the flow-local origin → violation slice, the capture escape slice); +- `render`/`render_github`/`render_msbuild`/`build_sarif` on the bridge path + (BR-V9), reusing `own_diagnostics::sarif`; +- tightening `verdicts.rs` to full equality on the existing goldens; +- the two decisions this checkpoint owes: the protocol analysis, and the + coordinate domain. diff --git a/docs/proposals/P-022-rust-core-migration.md b/docs/proposals/P-022-rust-core-migration.md index dd49dd32..06e660f4 100644 --- a/docs/proposals/P-022-rust-core-migration.md +++ b/docs/proposals/P-022-rust-core-migration.md @@ -9,7 +9,10 @@ child-issue DAG. Revised per the post-merge review in ## Implementation status > **Reconciled at `fdcb222`** against `rust/Cargo.toml`, the crate sources and -> the child-issue states under #250. Written fresh from the tree, not carried +> the child-issue states under #250, with the #259 checkpoint-4 rows below +> rewritten from the tree when that checkpoint landed +> ([`p022-bridge-verdict-checkpoint4.md`](../notes/p022-bridge-verdict-checkpoint4.md)). +> Written fresh from the tree, not carried > over from an earlier reconciliation — a stale status block patched with a > stale fix stays stale. Statuses are **checkpoint-level**, and each open step > separates its *normative* blocker (what its acceptance actually requires) from @@ -56,8 +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 | **not started** | the crate states its own boundary: "no diagnostics, no analysis" | -| 5 — full fact-to-verdict parity | **unblocked, not done** | its comparison set (message, severity, subject, resource kind, ordered Evidence) is now delivered by #255 — but the checkpoint still needs checkpoint 4's wiring to produce verdicts to compare | +| 4 — analysis wiring | **complete at the checkpoint-4 surface — 0/0/0 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`): **77 goldens** — the swept `ownir`/`lowered`/`summaries` corpora (58) plus 19 synthetic controls — carrying every `Finding` member; the cp4 replay compares identity, anchor, kind and tiering (all members but `message`/`related`/`flow`): **69 cases replayed, 5 refusals, 127 findings, 0/0/0**. The other **8** are a **named exclusion ledger the replay executes**, not a silent gap — two documents declaring obligation protocols (the OBL analysis is not ported; the bridge *refuses* them rather than return a list with a family missing), four 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 two #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. Mutations: see the checkpoint note | +| 4b — protocol analysis (OBL001–005) | **not started — recorded as its own checkpoint, not folded into cp5** | A gap in #259's checkpoint list: none of cp1–cp5 names the obligation-protocol analysis, while #259's final acceptance ("the full #258 family inventory") requires the protocol rows (BR-P3). `ownlang/obligations.py` is a path-sensitive analysis of its own — lattice, walker, matching — so it is a checkpoint of its own rather than a fourth job for cp5 (whose scope is messages, evidence and rendering). Its *normative* place: before #259's final acceptance; it does **not** block cp5. Until it lands the bridge **refuses** a protocol-bearing document (two reference documents in the verdict ledger) rather than return a verdict list with a family missing | +| 5 — full fact-to-verdict parity | **unblocked, not done** | the goldens already carry `message`, `related` and `flow`; cp5 is the message matrix (BR-V4), the evidence slices, the rendered surfaces (`render*`, `build_sarif` on the bridge path — BR-V9) and tightening the cp4 replay to full equality on the **same frozen goldens** — no regeneration beside the implementation. The `u32` coordinate boundary stays excluded until its contract decision lands; the owner's stated direction is a **Python-first tightening** of the coordinate domain (a negative source line is meaningless), as its own contract change with parity evidence — never "Rust is `u32`, so the reference is wrong" | **Open steps — each owned by exactly one child issue under #250:** @@ -72,7 +76,8 @@ was #258 alone, which is satisfied. Per the checkpoints #259 itself defines: | 7b | Rust `own-cli`: command/output/exit-code parity | #261 | blocked — needs the production bridge and the output surfaces | | 8 | Rust-default **cutover**, rollback gate, Python distribution removal | #262 | blocked by #260/#261 and final parity | -**Preferred queue:** #259 cp4 → cp5 → #260/#269. +**Preferred queue:** #259 cp5 → 4b (protocol analysis) → #260/#269. 4b does +not block cp5; #259's final acceptance needs it. The defensive limits that used to head this queue landed in #326, and the order was load-bearing rather than tidy. cp1 could report 0/0/0 only over a set with diff --git a/docs/proposals/README.md b/docs/proposals/README.md index c1174ebd..eadbc79a 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 open); Python authoritative until cutover | +| [P-022](P-022-rust-core-migration.md) | Rust core migration: crate DAG, patterns, prior art, differential oracle (Python = golden) | in execution — steps 0–4 built (#214/#249); step 5a done (full diagnostic contract, #255 via #319/#320/#321); step 5b SARIF done (#256; `.ownreport.json` struck — a buffer report needing the AST, not a diagnostics surface); step 6a done (`spec/Bridge.md`, #258); step 6b underway (`own-lowered`/`own-bridge`, #259: lowering and MOS parity landed; strict-door validation complete at 216 controls with no known divergence — the first 0/0/0 proved to be the ledger agreeing with its own author, and the second omitted two families that a Python-first defensive-limit change (#326) had to close before the third could measure them; analysis wiring complete at the checkpoint-4 surface — `check_facts` through the real analyses, Layer 3 goldens built, 69 cases replayed 0/0/0 with an executable 8-case exclusion ledger naming the protocol boundary, the `u32` coordinate boundary and two OD-1 door controls; full fact-to-verdict parity (cp5: messages, evidence, rendered surfaces) open); Python authoritative until cutover | | [P-023](P-023-architecture-guard.md) | Architecture guard (`Own.Arch`): rules.yaml intent model + dependency-graph gate + baseline ratchet | draft | | [P-024](P-024-security-audit-profile.md) | Security audit profile (external tools + SARIF adapters; rejects own scanner engine) | draft | | [P-025](P-025-obligation-protocols.md) | Obligation protocols (`Own.Protocols`): barrier-sensitive project invariants (OBL001–005) | first slice built (core + bridge + fixtures; extractor pending) | diff --git a/ownlang/verdicts.py b/ownlang/verdicts.py new file mode 100644 index 00000000..c0fed985 --- /dev/null +++ b/ownlang/verdicts.py @@ -0,0 +1,91 @@ +"""Layer 3 parity surface: the normalized verdict list (P-022 #259). + +A read-only, canonical JSON projection of what the OwnIR bridge *concluded* — +the `Finding` list `check_facts()` returns for one facts document, every field, +in the dataclass's declaration order — or, when the bridge refuses the +document, the `OwnIRError` text (spec/Bridge.md §6, layer 3: "the outer +contract"). The Rust `own-bridge` replays the same facts through +`own_bridge::check_facts` and must reproduce these documents; until then Python +is authoritative and `tests/test_verdict_fixtures.py --write` regenerates the +committed goldens. + +Strictly an OBSERVER, like `ownlang/lowered.py`: this module never mutates +facts, never changes a verdict, and is imported by nothing in the production +verdict path. + +Normalization decisions (frozen; changing any is a parity-contract change): + +* **One record per `Finding`, every field, in declaration order** — `file, + line, code, component, event, handler, message, kind, advisory, severity, + related, flow, ignore_reason, column`. A field added to `Finding` appears + here automatically, which is the point: the Rust replay parses the golden + strictly and goes red until it is taught the new member. Nothing is + dropped to make a checkpoint look complete — a replay declares which + members it compares (identity, anchor, kind and tiering at #259 cp4; + message and evidence at cp5); the golden always carries them all. +* **Order is the bridge's** (BR-V8: a stable sort on `(file, line, column or 0, + code)`, ties in construction order) — the list is serialized as returned, + never re-sorted here, so an ordering defect is visible as a diff. +* **Evidence triples** (`related`, `flow`) serialize as `[file, line, label]` + arrays; every optional scalar (`column`, `severity`, `ignore_reason`) is + `null` when absent — absence is data (`column` is never invented, BR-V5). +* **A refusal** (`OwnIRError` from `check_facts`: vocabulary skew, an unknown + resource kind, a core verdict the bridge cannot map back — BR-V3) projects + as `{"verdicts_version": ..., "error": ""}`, so the rejection text + is part of the surface exactly as the Layer 2 family pins it. +* Rendering is `json.dumps(indent=2, ensure_ascii=False)` + a trailing + newline; regeneration is deterministic for identical input. + +Fixture sharing: `tests/fixtures/verdicts/manifest.json` is the frozen case +ledger — the swept facts corpora (`tests/fixtures/{ownir,lowered,summaries}`), +the verdict-specific synthetic cases beside the manifest, and the Rust-side +exclusion ledger (documents the Rust core refuses by declared boundary, each +with its reason and an executable expectation the Rust replay asserts). +""" + +from __future__ import annotations + +import dataclasses +import json +from typing import Any + +from .ownir import Finding, OwnIRError, check_facts + +# The Layer 3 surface version. Bump on ANY normalization change above — the +# committed goldens and the Rust replay are both keyed to it. +VERDICTS_VERSION = 1 + +# `Finding`'s members in declaration order — the record shape, derived rather +# than listed, so the surface cannot silently lag the dataclass. +_FIELDS: tuple[str, ...] = tuple(f.name for f in dataclasses.fields(Finding)) + + +def _record(f: Finding) -> dict[str, Any]: + out: dict[str, Any] = {} + for name in _FIELDS: + value = getattr(f, name) + if name in ("related", "flow"): + value = [list(step) for step in value] + out[name] = value + return out + + +def project_verdicts(facts: dict[str, Any]) -> dict[str, Any]: + """Project one facts document's verdict list into the canonical Layer 3 + dict. A bridge refusal (`OwnIRError`) projects as `{"verdicts_version": + ..., "error": }` — the rejection text is part of the parity + surface. Never mutates `facts`.""" + try: + findings = check_facts(facts) + except OwnIRError as e: + return {"verdicts_version": VERDICTS_VERSION, "error": str(e)} + return { + "verdicts_version": VERDICTS_VERSION, + "findings": [_record(f) for f in findings], + } + + +def render_verdicts(facts: dict[str, Any]) -> str: + """The canonical serialized form: fixed field order, 2-space indent, + non-ASCII preserved, trailing newline. Byte-identical on re-run.""" + return json.dumps(project_verdicts(facts), indent=2, ensure_ascii=False) + "\n" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 43428259..c651d0f6 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -28,8 +28,12 @@ dependencies = [ name = "own-bridge" version = "0.1.0" dependencies = [ + "own-analysis", + "own-cfg", + "own-diagnostics", "own-ir", "own-lowered", + "own-syntax", "serde", "serde_json", ] diff --git a/rust/README.md b/rust/README.md index 20a8372c..320bf299 100644 --- a/rust/README.md +++ b/rust/README.md @@ -15,19 +15,21 @@ change. ## Status -Two of eight planned crates exist. The rest are design-only (see the crate -topology in P-022) — populated bottom-up, oracle-gated, in this order: +Seven of the planned crates exist (`own-lowered`, the typed Layer 2 surface, +joined the DAG with #259). The checkpoint-level truth lives in P-022's +implementation-status block; this table is the one-line orientation: | Crate | Status | What it is | |---|---|---| -| `own-ir` | **done** (step 1) | The OwnIR fact contract (`serde` types + schema-version gate) and the span/location leaf. Port of `ownlang/ownir.py`'s schema, not its ~2000 lines of bridge logic (that's `own-bridge`, later). | +| `own-ir` | **done** (step 1; strict door #259 cp1) | The OwnIR fact contract (`serde` types + the strict validator over the raw document) and the span/location leaf. Port of `ownlang/ownir.py`'s `load()`, not its bridge logic (that's `own-bridge`). | | `own-syntax` | **done** (step 2) | Lexer + recursive-descent parser + AST. Port of `ownlang/{lexer,parser,ast_nodes}.py`, with a **byte-identical error-text** contract against Python. | -| `own-cfg` | not started | AST → CFG lowering. | -| `own-analysis` | not started | The worklist/lattice solver: ownership, lifetime, effect, DI. | -| `own-diagnostics` | not started | `Diagnostic`/`Evidence` types + text/SARIF rendering. | -| `own-codegen` | not started | C# emission (`emit_*` templates), verdict-independent. | -| `own-bridge` | not started | The OwnIR bridge: facts → core AST, interprocedural MOS inference. | -| `own-cli` | not started | The binary; `own-oracle` is the dev-only differential harness alongside it. | +| `own-cfg` | **done** (step 3) | AST → CFG lowering, replaying the canonical CFG-JSON seam. | +| `own-analysis` | **done** (step 4) | The worklist/lattice solver plus ownership, lifetime, buffer-policy, effect and DI, with `(line, code)` parity on the `.own` corpus and the verdict `subject` the bridge maps through. | +| `own-diagnostics` | **done** (steps 5a/5b) | `Diagnostic`/`Evidence` model, canonical render text, the SARIF 2.1.0 projection. | +| `own-lowered` | **done** (#259) | The typed Layer 2 document + canonical emitter the bridge lowers into. | +| `own-bridge` | **in progress** (#259: cp1–cp4 done, cp5 open) | The OwnIR bridge: facts → Layer 2 → core AST → analyses → verdicts (`lower`, `dump_summaries`, `check_facts`). | +| `own-codegen` | not started (#257) | C# emission (`emit_*` templates), verdict-independent. | +| `own-cli` | not started (#261) | The binary; `own-oracle` is the dev-only differential harness alongside it. | ## Build & test @@ -39,9 +41,10 @@ cargo test ``` Same three commands the CI job `rust (fmt + clippy + tests)` runs -(`.github/workflows/ci.yml`) on every push. Latest run on `main`: 23 tests, -0 failures, ~1s (`own-ir`: 10, `own-syntax`: 12 unit + 1 integration -covering 24 fixture cases). +(`.github/workflows/ci.yml`) on every push. Every crate's parity suites +replay Python-authored fixtures under `tests/fixtures/` with zero Python +present (the counts live in each suite's assertions, not here — a number +in prose rots). `unsafe_code = "forbid"` workspace-wide, `clippy::pedantic`/`nursery` warn, `unwrap_used`/`indexing_slicing`/`arithmetic_side_effects`/`panic` deny — see diff --git a/rust/crates/own-analysis/src/lifetime.rs b/rust/crates/own-analysis/src/lifetime.rs index e8a95a40..9dfdf33d 100644 --- a/rust/crates/own-analysis/src/lifetime.rs +++ b/rust/crates/own-analysis/src/lifetime.rs @@ -7,8 +7,12 @@ //! longer region and it leaks (OWN014). Structural validation emits OWN030 (undefined //! lifetime), OWN031 (redeclared) and OWN036 (cyclic ordering). //! -//! Parity contract (#214): `(line, code)`. The AST is read through `own_cfg::ast` -//! (the CFG seam), so this crate keeps no production `own-syntax` edge. +//! Parity contract (#214): `(line, code)` — and, since the bridge's analysis +//! wiring (#259 cp4), the OWN014 **subject** `source#line` the Python analysis +//! stamps so the `OwnIR` bridge can attribute the escape to the original C# +//! subscription by identity (`ownir._handle_of`, BR-V3) instead of scraping +//! the message. The AST is read through `own_cfg::ast` (the CFG seam), so this +//! crate keeps no production `own-syntax` edge. use std::collections::{BTreeMap, BTreeSet}; @@ -152,7 +156,13 @@ fn check_fn( continue; }; if longer.get(self_lt).is_some_and(|s| s.contains(src_lt)) { - push(diags, "OWN014", sub.line); + // Python: `subject=f"{sub.source}#{sub.line}"` — the captured-by + // source's identity, invisible to rendering, read by the bridge. + let msg = title("OWN014").unwrap_or("OWN014"); + match Diagnostic::new("OWN014", msg, sub.line) { + Ok(d) => diags.push(d.with_subject(format!("{}#{}", sub.source, sub.line))), + Err(_) => debug_assert!(false, "OWN014 is a known code"), + } } } } diff --git a/rust/crates/own-analysis/src/ownership.rs b/rust/crates/own-analysis/src/ownership.rs index ff615d05..9a15de49 100644 --- a/rust/crates/own-analysis/src/ownership.rs +++ b/rust/crates/own-analysis/src/ownership.rs @@ -1,9 +1,14 @@ //! The ownership analysis — an exact port of `ownlang/analysis.py` (the flow- //! sensitive loans + permissions model), built on the generic [`solver`]. //! -//! Parity contract (#214 checkpoint 2): `(line, code)` on the `check` surface. -//! Message text, the evidence slice, subject/`resource_kind` and SARIF are later -//! steps and are deliberately not reproduced here (the diagnostic still carries a +//! Parity contract (#214 checkpoint 2): `(line, code)` on the `check` surface, +//! plus — since the `OwnIR` bridge's analysis wiring (#259 cp4) — the verdict's +//! **`subject`**: the symbol's stable `origin` identity (`name#line`), stamped +//! exactly where the Python `err(...)` call passes `subject=sym.origin`. The +//! bridge maps a core verdict back to a fact handle through that field and +//! nothing else (BR-V3), so it is identity, not presentation. Message text, +//! the evidence slice, `resource_kind` and SARIF remain later steps and are +//! deliberately not reproduced here (the diagnostic still carries a //! human-readable title as its message so it is never blank). //! //! RID semantics map directly: Python keys resource state on `id(sym)`; here a @@ -196,21 +201,43 @@ impl Lattice for StateFact { } /// A collector that emits diagnostics in phase 2 and is a no-op during the silent -/// fixpoint (phase 1) — the port of `_Analyzer.silent`. +/// fixpoint (phase 1) — the port of `_Analyzer.silent`. The collecting variant +/// carries the CFG so a verdict can be stamped with its symbol's `origin`. enum Emit<'a> { Silent, - Collect(&'a mut Vec), + Collect { + sink: &'a mut Vec, + cfg: &'a Cfg, + }, } impl Emit<'_> { + /// Emit `code` at `line` with **no** subject — the Python `err(...)` calls + /// that pass no `subject=` (the loan/permission codes OWN004/006/007-on- + /// consume/011/012/013/034/041). fn push(&mut self, code: &'static str, line: u32) { - if let Self::Collect(sink) = self { + self.push_at(code, line, None); + } + + /// Emit `code` at `line`, stamped with the subject of `sym`: the symbol's + /// stable `origin` (`name#line`, minted by `own-cfg` at the acquire/param + /// site and inherited across `move`/alias) — exactly where the Python + /// `err(...)` call passes `subject=sym.origin`. A symbol without an origin + /// yields no subject, as in Python. + fn push_at(&mut self, code: &'static str, line: u32, sym: Option) { + if let Self::Collect { sink, cfg } = self { // Every `code` is a compile-time TITLES constant, so `new` cannot // fail; the title doubles as a non-blank human message (message-text // parity is a later step and is not compared now). let msg = title(code).unwrap_or(code); match Diagnostic::new(code, msg, line) { - Ok(d) => sink.push(d), + Ok(d) => { + let subject = sym.and_then(|s| cfg.symbol(s).origin.clone()); + sink.push(match subject { + Some(subject) => d.with_subject(subject), + None => d, + }); + } Err(_) => debug_assert!(false, "own-analysis emitted an unknown code {code}"), } } @@ -245,18 +272,18 @@ fn state_problem(st: &State, sym: SymId, emit: &mut Emit<'_>, line: u32) -> bool let s = st.states(st.rid_of(sym)); if s & OWNED == 0 { if s & MOVED != 0 { - emit.push("OWN005", line); + emit.push_at("OWN005", line, Some(sym)); } else { - emit.push("OWN002", line); + emit.push_at("OWN002", line, Some(sym)); } return true; } if s & (RELEASED | ESCAPED) != 0 { - emit.push("OWN009", line); + emit.push_at("OWN009", line, Some(sym)); return true; } if s & MOVED != 0 { - emit.push("OWN010", line); + emit.push_at("OWN010", line, Some(sym)); return true; } false @@ -303,13 +330,18 @@ fn check_shared_borrowable(st: &State, owner: SymId, emit: &mut Emit<'_>, line: /// Report every RID still `OWNED` in `st` as a leak (OWN001), excluding a /// returned resource. Port of `_Analyzer.leak_check`. +/// +/// A RID is the id of the symbol that minted it (`State::mint`), so the leaked +/// resource's symbol — and the `origin` its subject is read from — is +/// `SymId(rid)`; that is the Python `_sym_by_id(rid)` lookup without the +/// object-identity indirection. fn leak_check(st: &State, at_line: u32, emit: &mut Emit<'_>, exclude: Option) { for (&rid, &states) in &st.var { if Some(rid) == exclude { continue; } if states & OWNED != 0 { - emit.push("OWN001", at_line); + emit.push_at("OWN001", at_line, Some(SymId(rid))); } } } @@ -365,9 +397,9 @@ impl<'a> Ownership<'a> { consume_like(st, sym, emit, line, "OWN007"); if let Some(buf) = &s.buffer { if buf.stack_backed() { - emit.push("OWN016", line); + emit.push_at("OWN016", line, Some(sym)); } else { - emit.push("OWN017", line); + emit.push_at("OWN017", line, Some(sym)); } } let rid = st.rid_of(sym); @@ -433,11 +465,11 @@ impl<'a> Ownership<'a> { // released on some path") but both are OWN003; merged since only // (line, code) is compared, and `s == RELEASED` ⊆ `s & RELEASED`. if s & RELEASED != 0 { - emit.push("OWN003", *line); + emit.push_at("OWN003", *line, Some(*sym)); } else if !state_problem(st, *sym, emit, *line) { let (shared, mutable) = loans_on(st, *sym); if shared > 0 || mutable { - emit.push("OWN008", *line); + emit.push_at("OWN008", *line, Some(*sym)); } } st.var.insert(rid, RELEASED); @@ -461,8 +493,8 @@ impl<'a> Ownership<'a> { Kind::Plain => {} } } - Instr::Overspan { line, .. } => { - emit.push("OWN025", *line); + Instr::Overspan { sym, line } => { + emit.push_at("OWN025", *line, Some(*sym)); } Instr::Invoke { args, line, .. } => { for (opt_sym, eff) in args { @@ -504,20 +536,22 @@ impl<'a> Ownership<'a> { let s = st.states(rid); if s & OWNED == 0 { if s & MOVED != 0 { - emit.push("OWN005", *line); + emit.push_at("OWN005", *line, Some(*sid)); } else { - emit.push("OWN002", *line); + emit.push_at("OWN002", *line, Some(*sid)); } } else { let (shared, mutable) = loans_on(st, *sid); let symbol = self.cfg.symbol(*sid); + // Unlike the consume-path OWN007 (`_consume_like`, no + // subject), the return-path OWN007 carries one in Python. if shared > 0 || mutable { - emit.push("OWN007", *line); + emit.push_at("OWN007", *line, Some(*sid)); } else if let Some(buf) = &symbol.buffer { if buf.stack_backed() { - emit.push("OWN015", *line); + emit.push_at("OWN015", *line, Some(*sid)); } else { - emit.push("OWN017", *line); + emit.push_at("OWN017", *line, Some(*sid)); } } } @@ -629,7 +663,10 @@ pub fn analyze(cfg: &Cfg) -> Vec { // Phase 2a: one emitting transfer per block, on its converged in-state. for &bid in &reachable { let in_st = converged_state(solution.in_fact(bid), bid); - let mut emit = Emit::Collect(&mut diags); + let mut emit = Emit::Collect { + sink: &mut diags, + cfg, + }; let out = own.transfer_block(bid, &in_st, &mut emit); out_states.insert(bid, out); } @@ -647,7 +684,10 @@ pub fn analyze(cfg: &Cfg) -> Vec { } let at_line = last_line(cfg, blk); if let Some(st) = out_states.get(&bid) { - let mut emit = Emit::Collect(&mut diags); + let mut emit = Emit::Collect { + sink: &mut diags, + cfg, + }; leak_check(st, at_line, &mut emit, None); } } diff --git a/rust/crates/own-analysis/tests/subject.rs b/rust/crates/own-analysis/tests/subject.rs new file mode 100644 index 00000000..16502447 --- /dev/null +++ b/rust/crates/own-analysis/tests/subject.rs @@ -0,0 +1,122 @@ +//! The verdict **subject** contract (#259 cp4): every diagnostic the Python +//! analysis stamps with `subject=sym.origin` carries the same `name#line` +//! identity here, and the ones Python leaves subject-less stay subject-less. +//! +//! Asserted through the production `check_module` surface (not a private +//! emitter), because the `OwnIR` bridge maps a core verdict back to a fact +//! handle by exactly this field (BR-V3 map-or-raise) — a subject that is +//! present but wrong, or missing where Python has one, is a bridge rejection +//! or a mis-anchored finding, never a cosmetic difference. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use own_analysis::check_module; + +const PRELUDE: &str = "module M\n\ + resource Conn { acquire open release close }\n\ + extern fn Hash(borrow Conn);\n\ + extern fn Store(consume Conn);\n"; + +/// `(line, code, subject)` for every diagnostic of `body` (appended after the +/// 4-line prelude, so body line 1 is source line 5). +fn subjects(body: &str) -> Vec<(u32, String, Option)> { + let module = own_syntax::parse(&format!("{PRELUDE}{body}")).expect("parses"); + check_module(&module) + .into_iter() + .map(|d| (d.line, d.code, d.subject)) + .collect() +} + +/// The expected-subject spelling: the `Option` IS the assertion shape (a +/// diagnostic's subject is optional), so wrapping here is the point. +#[allow(clippy::unnecessary_wraps)] +fn s(x: &str) -> Option { + Some(x.to_owned()) +} + +#[test] +fn leak_carries_the_acquire_origin() { + // `let c = acquire Conn(1);` on line 6 → origin `c#6` (the acquire's line). + let got = subjects("fn f() {\n let c = acquire Conn(1);\n}\n"); + assert_eq!(got, vec![(6, "OWN001".to_owned(), s("c#6"))]); +} + +#[test] +fn param_leak_carries_the_param_origin() { + // An owned parameter is minted at the fn line (5): origin `p#5`. The leak + // itself anchors at line 0 — an empty body has no instruction to borrow a + // line from, the same anchor Python reports (`(line, code)` parity). + let got = subjects("fn f(p: Conn) {\n}\n"); + assert_eq!(got, vec![(0, "OWN001".to_owned(), s("p#5"))]); +} + +#[test] +fn use_after_release_and_double_release_carry_the_origin() { + let got = + subjects("fn f() {\n let c = acquire Conn(1);\n release c;\n use c;\n release c;\n}\n"); + assert_eq!( + got, + vec![ + (8, "OWN002".to_owned(), s("c#6")), + (9, "OWN003".to_owned(), s("c#6")), + ] + ); +} + +#[test] +fn return_after_release_carries_the_origin() { + let got = + subjects("fn f() -> Conn {\n let c = acquire Conn(1);\n release c;\n return c;\n}\n"); + assert_eq!(got, vec![(8, "OWN002".to_owned(), s("c#6"))]); +} + +#[test] +fn origin_is_inherited_across_a_move() { + // `let d = move c;` keeps c's origin on d (Python `dst.origin = src.origin`): + // the use-after-move names `c#6`, and the leak of `d` is attributed to `c#6` + // too. `check_module` sorts by (line, code), so OWN001 precedes OWN005. + let got = subjects("fn f() {\n let c = acquire Conn(1);\n let d = move c;\n use c;\n}\n"); + assert_eq!( + got, + vec![ + (8, "OWN001".to_owned(), s("c#6")), + (8, "OWN005".to_owned(), s("c#6")), + ] + ); +} + +#[test] +fn loan_permission_codes_stay_subject_less_like_python() { + // `borrow b as r { borrow_mut b as m { } }`: a mutable borrow while a shared + // one is live is OWN006 — Python's `err(...)` passes no subject there. + let got = subjects( + "fn f() {\n let b = acquire Conn(1);\n borrow b as r {\n borrow_mut b as m {\n }\n }\n release b;\n}\n", + ); + assert_eq!(got, vec![(8, "OWN006".to_owned(), None)]); +} + +#[test] +fn overspan_carries_the_buffer_origin_with_its_column() { + // A buffer intent's origin is `name#line:col` (the bridge's flow-local + // pooled buffers go through `acquire`, not this path — pinned for the .own + // surface, which own-cli will need). + let got = subjects("fn f() {\n let b = Buffer.pooled(4);\n overspan b;\n release b;\n}\n"); + assert_eq!(got, vec![(7, "OWN025".to_owned(), s("b#6:11"))]); +} + +#[test] +fn region_escape_carries_the_source_identity() { + // OWN014's subject is `source#line` — the captured-by source, not `self`. + let src = "module M\n\ + lifetime App;\n\ + lifetime ViewModel < App;\n\ + fn VM(bus: EventSource lifetime App) lifetime ViewModel {\n\ + subscribe self to bus;\n\ + }\n"; + let module = own_syntax::parse(src).expect("parses"); + let got: Vec<(u32, String, Option)> = check_module(&module) + .into_iter() + .map(|d| (d.line, d.code, d.subject)) + .collect(); + assert_eq!(got, vec![(5, "OWN014".to_owned(), s("bus#5"))]); +} diff --git a/rust/crates/own-bridge/Cargo.toml b/rust/crates/own-bridge/Cargo.toml index 66684a1d..64466ec5 100644 --- a/rust/crates/own-bridge/Cargo.toml +++ b/rust/crates/own-bridge/Cargo.toml @@ -1,7 +1,10 @@ -# The OwnIR -> Layer 2 lowering (P-022 #259 slice 3): a pure transformation -# crate. Production API is `OwnIr -> Result` — -# no filesystem, no CLI, no diagnostics, no analysis side effects; fixture -# I/O lives only in the integration tests. +# The OwnIR bridge (P-022 step 6b, #259): facts -> Layer 2 lowering -> the +# core -> verdicts. A pure transformation crate — no filesystem, no CLI; +# fixture I/O lives only in the integration tests. It is the one deliberately +# WIDE consumer in the DAG (ir + lowered + syntax + cfg + analysis + +# diagnostics): that width is its job, and the constraint runs the other way — +# only entry-point crates (own-cli, a future own-lsp/own-capi) may depend on +# it, never a core crate (P-022 §fitness; own-diagnostics/tests/dag.rs). [package] name = "own-bridge" version = "0.1.0" @@ -9,11 +12,18 @@ edition.workspace = true rust-version.workspace = true license.workspace = true publish.workspace = true -description = "OwnIR facts -> normalized Layer 2 lowering (Python to_module parity)" +description = "OwnIR bridge: facts -> Layer 2 lowering -> core analyses -> verdicts (Python check_facts parity)" [dependencies] own-ir = { path = "../own-ir" } own-lowered = { path = "../own-lowered" } +# The analysis wiring (#259 cp4): the lowering CONSTRUCTS the core AST +# (own-syntax), drives own-cfg/own-analysis through `check_module`, and reads +# the verdict model (own-diagnostics) to map results back to fact handles. +own-syntax = { path = "../own-syntax" } +own-cfg = { path = "../own-cfg" } +own-analysis = { path = "../own-analysis" } +own-diagnostics = { path = "../own-diagnostics" } serde_json.workspace = true [dev-dependencies] diff --git a/rust/crates/own-bridge/src/ast.rs b/rust/crates/own-bridge/src/ast.rs new file mode 100644 index 00000000..b8b90465 --- /dev/null +++ b/rust/crates/own-bridge/src/ast.rs @@ -0,0 +1,262 @@ +//! Layer 2 → the core AST: the `own_syntax::ast::Module` that Python's +//! `to_module` hands `check_module`, rebuilt from the normalized lowered +//! document (#259 cp4). +//! +//! The Layer 2 projection (`ownlang/lowered.py`) is lossless for everything +//! the analyses read: every node line the bridge carries is serialized, and +//! the lines it drops are the ones `to_module` fixes at `0` — resource, +//! extern, lifetime and function declarations, capture params, and every +//! `TypeRef`. So building the AST from the proven Layer 2 document (27/27 +//! byte-exact against the reference) rather than from the facts a second time +//! composes the checkpoint-2 evidence instead of re-deriving it: a lowering +//! bug is visible at the Layer 2 seam before it can hide behind a verdict. +//! +//! **The one representability boundary lives here.** The core's line domain +//! is `u32` (a parser-derived position), while a fact coordinate on the +//! tolerant door is any integer the reference's `_as_int` passes through, and +//! even the strict door admits every signed 64-bit value (`spec/OwnIR.md` +//! §4.2). A coordinate outside `0..=u32::MAX` on a lowered node is therefore +//! refused loudly — a Rust-only rejection of a document the reference +//! analyzes, declared and measured as a cp4 divergence family (the verdict +//! fixture ledger pins it), never clamped or silently dropped. + +// `redundant_pub_crate` (nursery) conflicts with the workspace's DENY of +// `unreachable_pub` for items in private modules; pub(crate) is the honest +// visibility here (same stance as `mos.rs`). +#![allow(clippy::redundant_pub_crate)] + +use crate::BridgeError; +use own_lowered::{Function, LoweredDocument, Param, Stmt, TypeShape}; +use own_syntax::ast::{ + Acquire, AliasJoin, Call, Effect, EffectParam, Expr, ExternDecl, FnDecl, If, Let, LifetimeDecl, + MemberRole, Module, Overspan, Release, ResourceDecl, ResourceMember, Return, Stmt as AstStmt, + Subscribe, TypeRef, Use, VarRef, While, +}; + +/// `u32` or refuse: the declared coordinate boundary (see the module docs). +pub(crate) fn core_line(line: i64, what: &str) -> Result { + u32::try_from(line).map_err(|_| { + BridgeError(format!( + "source line {line} on {what} is outside the core's line domain \ + (0..=4294967295): the reference analyzes this coordinate, this core \ + cannot represent it — a declared #259 cp4 divergence family, not a \ + silent clamp (spec/OwnIR.md §4.2 bounds coordinates to signed 64 bits)" + )) + }) +} + +fn type_ref(t: &TypeShape) -> TypeRef { + TypeRef { + name: t.name.clone(), + borrowed: t.borrowed, + mutable: t.mutable, + line: 0, + } +} + +fn effect(name: &str) -> Result { + match name { + "consume" => Ok(Effect::Consume), + "borrow" => Ok(Effect::Borrow), + "borrow_mut" => Ok(Effect::BorrowMut), + "plain" => Ok(Effect::Plain), + other => Err(BridgeError(format!( + "Layer 2 extern effect {other:?} has no core Effect — the lowering emits \ + only consume/borrow/borrow_mut/plain" + ))), + } +} + +fn member_role(role: &str) -> Result { + match role { + "acquire" => Ok(MemberRole::Acquire), + "release" => Ok(MemberRole::Release), + other => Err(BridgeError(format!( + "Layer 2 resource member role {other:?} has no core MemberRole" + ))), + } +} + +fn param(p: &Param) -> Result { + Ok(own_syntax::ast::Param { + name: p.handle.clone(), + ty: type_ref(&p.type_shape), + line: core_line(p.line, &format!("param '{}'", p.handle))?, + lifetime: p.lifetime.clone(), + }) +} + +fn stmts(body: &[Stmt]) -> Result, BridgeError> { + body.iter().map(stmt).collect() +} + +fn stmt(s: &Stmt) -> Result { + Ok(match s { + Stmt::Acquire { + handle, + resource, + line, + } => { + let line = core_line(*line, &format!("acquire of '{handle}'"))?; + AstStmt::Let(Let { + name: handle.clone(), + rhs: Expr::Acquire(Acquire { + resource: resource.clone(), + args: Vec::new(), + line, + }), + line, + }) + } + Stmt::Release { handle, line } => AstStmt::Release(Release { + var: handle.clone(), + line: core_line(*line, &format!("release of '{handle}'"))?, + }), + Stmt::Use { handle, line } => AstStmt::Use(Use { + var: handle.clone(), + line: core_line(*line, &format!("use of '{handle}'"))?, + }), + Stmt::Overspan { handle, line } => AstStmt::Overspan(Overspan { + var: handle.clone(), + line: core_line(*line, &format!("overspan of '{handle}'"))?, + }), + Stmt::Return { handle, line } => AstStmt::Return(Return { + var: handle.clone(), + line: core_line(*line, "return")?, + }), + Stmt::AliasJoin { handle, src, line } => AstStmt::AliasJoin(AliasJoin { + name: handle.clone(), + src: src.clone(), + line: core_line(*line, &format!("alias_join of '{handle}'"))?, + }), + Stmt::Call { callee, args, line } => { + // Python: `VarRef(localmap.get(a, a), line)` — every argument is a + // name reference carrying the CALL's line. + let line = core_line(*line, &format!("call to '{callee}'"))?; + AstStmt::Call(Call { + callee: callee.clone(), + args: args + .iter() + .map(|a| { + Expr::VarRef(VarRef { + name: a.clone(), + line, + }) + }) + .collect(), + line, + }) + } + Stmt::Subscribe { source, line } => AstStmt::Subscribe(Subscribe { + source: source.clone(), + line: core_line(*line, &format!("subscribe to '{source}'"))?, + }), + Stmt::If { + cond, + then, + r#else, + line, + } => AstStmt::If(If { + cond_text: cond.clone(), + then_body: stmts(then)?, + else_body: stmts(r#else)?, + line: core_line(*line, "if")?, + }), + Stmt::While { cond, body, line } => AstStmt::While(While { + cond_text: cond.clone(), + body: stmts(body)?, + line: core_line(*line, "while")?, + }), + }) +} + +fn function(f: &Function) -> Result { + Ok(FnDecl { + name: f.name.clone(), + params: f.params.iter().map(param).collect::>()?, + ret: f.ret.as_ref().map(type_ref), + body: stmts(&f.body)?, + line: 0, + lifetime: f.lifetime.clone(), + }) +} + +/// Rebuild the core `Module` from a Layer 2 document — the AST `to_module` +/// returns, node for node (declaration lines fixed at `0`, exactly as the +/// reference constructs them). +/// +/// # Errors +/// [`BridgeError`] for a coordinate outside the core's `u32` line domain (the +/// declared boundary above), or a Layer 2 vocabulary value with no core twin +/// (unreachable for a document the lowering itself produced). +pub(crate) fn to_module(doc: &LoweredDocument) -> Result { + let resources = doc + .resources + .iter() + .map(|r| { + Ok(ResourceDecl { + name: r.name.clone(), + members: r + .members + .iter() + .map(|m| { + Ok(ResourceMember { + role: member_role(&m.role)?, + name: m.name.clone(), + line: 0, + }) + }) + .collect::>()?, + line: 0, + emit_type: None, + emit_acquire: None, + emit_release: None, + emit_borrow: None, + kind: r.kind.clone(), + }) + }) + .collect::, BridgeError>>()?; + let externs = doc + .externs + .iter() + .map(|e| { + Ok(ExternDecl { + name: e.name.clone(), + params: e + .params + .iter() + .map(|p| { + Ok(EffectParam { + effect: effect(&p.effect)?, + type_name: p.type_name.clone(), + line: 0, + }) + }) + .collect::>()?, + ret: None, + line: 0, + }) + }) + .collect::, BridgeError>>()?; + let lifetimes = doc + .lifetimes + .iter() + .map(|lt| LifetimeDecl { + name: lt.name.clone(), + longer: lt.longer.clone(), + line: 0, + }) + .collect(); + Ok(Module { + name: doc.module.clone(), + resources, + externs, + functions: doc + .functions + .iter() + .map(function) + .collect::>()?, + policies: Vec::new(), + lifetimes, + }) +} diff --git a/rust/crates/own-bridge/src/lib.rs b/rust/crates/own-bridge/src/lib.rs index 31a358d3..a7602d75 100644 --- a/rust/crates/own-bridge/src/lib.rs +++ b/rust/crates/own-bridge/src/lib.rs @@ -1,41 +1,52 @@ -//! `own-bridge` — the `OwnIR` facts → Layer 2 lowering (P-022 #259 slice 3). +//! `own-bridge` — the `OwnIR` bridge (P-022 step 6b, #259): facts → the core +//! → verdicts, the port of `ownlang/ownir.py` beyond its schema. //! -//! The Rust port of `ownlang/ownir.py::to_module` **restricted to the behavior -//! the shared Layer 2 fixtures exercise**: routing R1–R6, global `sub_`/`cap_` -//! and `parg_`/`loc_` handle minting, capture/DI lifetime regions, flow -//! lowering with the local map and kill-on-rebind, branch-local hoisting with -//! its negative gates, `alias_join`, unmapped references, call lowering, the -//! `$consume`/`$borrow`/`$borrow_mut` channels, the precise-overload channel -//! vs the merged-may kill site, in-branch untrack vs top-level kill site, -//! fresh-result minting, and the fail-loud flow-op vocabulary. +//! Three surfaces, one per landed checkpoint family: //! -//! **Pure transformation**: [`lower`] maps a typed [`own_ir::OwnIr`] document -//! to an [`own_lowered::LoweredDocument`] (or a [`BridgeError`] whose message -//! text is part of the parity surface — Python projects it as the `Rejected` -//! form), and [`dump_summaries`] renders the MOS summaries document -//! byte-identically to `python -m ownlang summaries` over the shared -//! scalar-metadata parity domain (the inference layer's parity artifact, -//! spec/Inference.md §8; see the function's contract for the domain -//! boundary). No filesystem, no CLI, no -//! diagnostics, no analysis. `OwnIR` validation parity, MOS contract -//! *changes*, and analysis wiring stay out of scope (#294 OD-2 landed: -//! IR4-everywhere — `tolerant_unknown_kind` is a shared `rust_replay` case -//! whose `Rejected` golden pins the fail-loud text on both sides). +//! * [`lower`] — `to_module` as the normalized **Layer 2** document (cp2): +//! routing R1–R6, global `sub_`/`cap_` and `parg_`/`loc_` handle minting, +//! capture/DI lifetime regions, flow lowering with the local map and +//! kill-on-rebind, branch-local hoisting with its negative gates, +//! `alias_join`, unmapped references, call lowering, the `$consume`/ +//! `$borrow`/`$borrow_mut` channels, the precise-overload channel vs the +//! merged-may kill site, in-branch untrack vs top-level kill site, +//! fresh-result minting, and the fail-loud flow-op vocabulary; +//! * [`dump_summaries`] — the MOS summaries document (cp3), byte-identical to +//! `python -m ownlang summaries` over the shared scalar-metadata parity +//! domain (spec/Inference.md §8; see the function's contract for the +//! domain boundary); +//! * [`check_facts`] — the **analysis wiring** (cp4): the lowered module +//! through `own_analysis::check_module` (ownership, lifetime, buffer +//! policy), the `services[]`/`effects[]` blocks through the DI and effect +//! analyses, plus the OWN050/051/052 advisory side paths, mapped back to +//! their C# anchors per spec/Bridge.md §5 — at the checkpoint-4 surface +//! (identity, anchor, kind and tiering; messages and evidence are cp5). //! -//! The oracle is byte-exact: for every `rust_replay: true` manifest case, -//! `facts → OwnIr::from_json → lower → own_lowered::to_canonical_json` must -//! equal the committed Python golden (`tests/replay.rs`), and every -//! summaries-family case must reproduce its `*.summaries.json` golden -//! through [`dump_summaries`] (`tests/summaries.rs`). The goldens are -//! expected output ONLY — never an input to construction. +//! **Pure transformation**, still: a typed [`own_ir::OwnIr`] document in, +//! values out (or a [`BridgeError`] whose message text is part of the parity +//! surface — Python projects it as the `Rejected` form). No filesystem, no +//! CLI. The bridge prepares analysis inputs and maps analysis outputs; it +//! owns no solver, no dataflow and no graph algorithm (BR-B1) — every verdict +//! comes from `own-analysis`, every anchor is the analysis's own selection, +//! and a verdict the bridge cannot attribute to a fact handle is a refusal, +//! never a dropped finding (BR-V3). +//! +//! The oracles are Python-authored and replayed with zero Python: the Layer 2 +//! goldens (`tests/replay.rs`), the summaries goldens (`tests/summaries.rs`) +//! and the verdict goldens (`tests/verdicts.rs`). Goldens are expected output +//! ONLY — never an input to construction. +mod ast; mod dump; mod lower; mod mos; +mod verdict; use own_ir::OwnIr; use own_lowered::LoweredDocument; +pub use verdict::Finding; + /// A lowering rejection — the Rust twin of Python's `OwnIRError` from /// `to_module`. The message TEXT is part of the Layer 2 parity surface /// (a fail-loud golden pins it byte-for-byte). @@ -82,3 +93,22 @@ pub fn lower(facts: &OwnIr) -> Result { pub fn dump_summaries(facts: &OwnIr) -> Result { dump::dump_summaries(facts) } + +/// Run the core over one `OwnIR` facts document and return its findings. +/// +/// The port of `ownlang/ownir.py::check_facts` at the #259 checkpoint-4 +/// surface — every finding mapped back to its C# anchor (see [`Finding`] for +/// the members carried). Deterministic: the list is deduplicated on the +/// reference's key (BR-V7) and stably sorted by `(file, line, column, code)` +/// (BR-V8). +/// +/// # Errors +/// [`BridgeError`] when the lowering fails loud (vocabulary skew, an unknown +/// resource kind), when a core verdict cannot be attributed to a fact handle +/// (BR-V3 — the reference's `OwnIRError`), when a coordinate falls outside +/// the core's `u32` line domain, or when the document declares an obligation +/// protocol (the protocol analysis is not wired yet; refused rather than +/// silently incomplete). +pub fn check_facts(facts: &OwnIr) -> Result, BridgeError> { + verdict::check_facts(facts) +} diff --git a/rust/crates/own-bridge/src/lower.rs b/rust/crates/own-bridge/src/lower.rs index ea76904a..53ef8d26 100644 --- a/rust/crates/own-bridge/src/lower.rs +++ b/rust/crates/own-bridge/src/lower.rs @@ -1,5 +1,12 @@ //! The `ownlang/ownir.py::to_module` port — `OwnIR` facts → the normalized -//! Layer 2 document, restricted to the behavior the shared fixtures exercise. +//! Layer 2 document, restricted to the behavior the shared fixtures exercise, +//! plus (since the analysis wiring, #259 cp4) the three verdict-side outputs +//! `to_module` hands `check_facts` beside the `Module`: the **full handle +//! records** (`{**sub, component, file}` / the flow-local dicts, `column` +//! included — the identity every core verdict maps back through, BR-V3), the +//! OWN051 advisories minted during lowering (BR-L8), and the solve-failure +//! reason for OWN052 (BR-M1). [`lower`] keeps returning the Layer 2 document +//! alone; [`lower_full`] returns all four. //! //! The walk is deliberately DICT-SHAPED: the typed [`OwnIr`] document is //! re-serialized to a JSON value once and lowered by the same key-by-key @@ -37,7 +44,43 @@ use own_lowered::{ use serde_json::{Map, Value}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -type Obj = Map; +pub(crate) type Obj = Map; + +/// Everything `to_module(facts, notes, advisories)` produces: the lowered +/// document, the handle → record map, the OWN052 reasons and the OWN051 notes. +#[derive(Debug)] +pub(crate) struct Lowering { + pub doc: LoweredDocument, + /// Handle → the Python-shaped record (`{**sub, "component", "file"[, + /// "di_source_life"]}` for a subscription fact; the flow-local dict for a + /// `parg_`/`loc_` handle). `check_facts` reads a verdict's anchor, kind and + /// tiering off this record and nothing else. + pub handles: HashMap, + /// `notes`: one `"{type}: {exc}"` reason per failed MOS solve (INF-F6). + pub mos_notes: Vec, + /// `advisories`: the OWN051 notes, in lowering order (function order, then + /// the flow walk's pre-order). + pub advisories: Vec, +} + +/// One OWN051 advisory as `to_module` mints it: the owned local `arg` handed +/// to `callee` at a `may`/`unknown` position on `line` of `file`, inside the +/// function `component` (BR-L8, INF-A5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Own051 { + pub file: String, + pub line: i64, + pub component: String, + pub arg: String, + pub callee: String, + pub transfer: &'static str, +} + +/// `_as_col`: the optional 1-based column, or `None` for anything that is not +/// a real coordinate (absent, `null`, a bool, a float, a string, `0`, negative). +pub(crate) fn as_col(v: Option<&Value>) -> Option { + v.and_then(Value::as_i64).filter(|c| *c >= 1) +} // --- Python-semantics helpers ------------------------------------------------ @@ -849,12 +892,18 @@ pub(crate) fn build_skeletons(raw_fns: &[Value]) -> Vec { // --- the optimistic-default machinery (untrack / kill sites) ------------------ -/// `_unverified_transfer_calls`, reduced to the arg-name set `to_module` -/// derives from it (the OWN051 advisory channel does not touch the lowered -/// document, so the callee/transfer/line tuple members are not carried). -fn unverified_arg_names(nodes: &[Value], mos: &Mos) -> HashSet { - let mut out = HashSet::new(); - fn walk(nodes: &[Value], mos: &Mos, out: &mut HashSet) { +/// `_unverified_transfer_calls`: every `(arg, callee, transfer, line)` where a +/// call hands an argument to a summarized callee position whose resolved +/// transfer is `may`/`unknown`, in the flow walk's pre-order. Two consumers, +/// as in the reference: the arg names become the untrack set, and each hit +/// on an owned local becomes an OWN051 advisory (`line` is `_as_int` of the +/// call's line; the transfer is the `Transfer` enum's wire value). +fn unverified_transfer_calls( + nodes: &[Value], + mos: &Mos, +) -> Vec<(String, String, &'static str, i64)> { + let mut out = Vec::new(); + fn walk(nodes: &[Value], mos: &Mos, out: &mut Vec<(String, String, &'static str, i64)>) { for n in nodes { let Some(n) = n.as_object() else { continue }; match get_str(n, "op") { @@ -865,10 +914,15 @@ fn unverified_arg_names(nodes: &[Value], mos: &Mos) -> HashSet { for (j, a) in args.iter().enumerate() { let j = i64::try_from(j).unwrap_or(i64::MAX); let ps = summ.params.iter().find(|q| q.index == j); - if ps.is_some_and(|q| { - matches!(q.transfer, Transfer::May | Transfer::Unknown) - }) { - out.insert(py_str(a)); + if let Some(q) = ps { + if matches!(q.transfer, Transfer::May | Transfer::Unknown) { + out.push(( + py_str(a), + callee.clone(), + q.transfer.as_str(), + as_int(n.get("line")), + )); + } } } } @@ -1017,14 +1071,18 @@ fn branch_hoist_safe( analyze(nodes, false, name, &is_acq).0 } -/// `_hoisted_branch_locals`: name → (first branch-acquire line, pool kind). +/// `_hoisted_branch_locals`: name → (first branch-acquire line, that same +/// acquire's column, pool kind). Line and column are read off the SAME +/// first-seen acquire node, so a hoisted local never carries one node's line +/// with another's column (#317). fn hoisted_branch_locals( nodes: &[Value], mos: &Mos, first_party: &HashSet, -) -> HashMap { +) -> HashMap, bool)> { let mut acq_depth: HashMap = HashMap::new(); let mut acq_line: HashMap = HashMap::new(); + let mut acq_col: HashMap> = HashMap::new(); let mut acq_pool: HashMap = HashMap::new(); let mut ref_depth: HashMap = HashMap::new(); let mut loop_acq: HashSet = HashSet::new(); @@ -1034,6 +1092,7 @@ fn hoisted_branch_locals( first_party: &'a HashSet, acq_depth: &'a mut HashMap, acq_line: &'a mut HashMap, + acq_col: &'a mut HashMap>, acq_pool: &'a mut HashMap, ref_depth: &'a mut HashMap, loop_acq: &'a mut HashSet, @@ -1062,6 +1121,9 @@ fn hoisted_branch_locals( let d = w.acq_depth.entry(acq.clone()).or_insert(depth); *d = (*d).min(depth); w.acq_line.entry(acq.clone()).or_insert(line); + w.acq_col + .entry(acq.clone()) + .or_insert_with(|| as_col(n.get("column"))); if op == Some("acquire") && n.get("kind").and_then(Value::as_str) == Some("pool") { w.acq_pool.insert(acq.clone(), true); } @@ -1101,6 +1163,7 @@ fn hoisted_branch_locals( first_party, acq_depth: &mut acq_depth, acq_line: &mut acq_line, + acq_col: &mut acq_col, acq_pool: &mut acq_pool, ref_depth: &mut ref_depth, loop_acq: &mut loop_acq, @@ -1120,6 +1183,7 @@ fn hoisted_branch_locals( name.clone(), ( acq_line.get(name).copied().unwrap_or(0), + acq_col.get(name).copied().flatten(), acq_pool.get(name).copied().unwrap_or(false), ), ) @@ -1168,15 +1232,53 @@ fn want_maybe(rec: &Obj, key: &str) -> Result, BridgeError> { } } -/// A subscription-fact handle record: `{**sub, component, file[, di_source_life]}` -/// projected through the `_HANDLE_KEYS` allowlist by key MEMBERSHIP. -fn subscription_entry( - handle: &str, +/// The handle map under construction: the Layer 2 entries (in mint order) and +/// the full Python-shaped records the verdict mapping reads. Every handle is +/// pushed through here once, so the two views can never disagree. +#[derive(Debug, Default)] +struct HandleStore { + entries: Vec, + records: HashMap, +} + +impl HandleStore { + fn push(&mut self, handle: &str, rec: Obj) -> Result<(), BridgeError> { + self.entries.push(handle_entry(handle, &rec)?); + self.records.insert(handle.to_owned(), rec); + Ok(()) + } +} + +/// A record projected through the `_HANDLE_KEYS` allowlist by key MEMBERSHIP +/// — the Layer 2 view of `{**sub, component, file[, di_source_life]}` or of a +/// flow-local dict (whose `column` is verdict-side metadata outside the +/// allowlist). +fn handle_entry(handle: &str, rec: &Obj) -> Result { + Ok(HandleEntry { + handle: handle.to_owned(), + component: want_str(rec, "component")?, + file: want_str(rec, "file")?, + line: want_i64(rec, "line")?, + event: want_str(rec, "event")?, + handler: want_str(rec, "handler")?, + resource: want_str(rec, "resource")?, + released: want_bool(rec, "released")?, + source: want_maybe(rec, "source")?, + source_type: want_maybe(rec, "source_type")?, + di_source_life: want_str(rec, "di_source_life")?, + type_name: want_maybe(rec, "type")?, + ever_released: want_bool(rec, "ever_released")?, + pool: want_bool(rec, "pool")?, + }) +} + +/// A subscription-fact handle record: `{**sub, component, file[, di_source_life]}`. +fn subscription_record( sub: &Obj, cname: &str, comp_file: Option<&Value>, di_source_life: Option<&str>, -) -> Result { +) -> Obj { let mut rec = sub.clone(); rec.insert("component".to_owned(), Value::String(cname.to_owned())); rec.insert( @@ -1188,50 +1290,40 @@ fn subscription_entry( if let Some(dl) = di_source_life { rec.insert("di_source_life".to_owned(), Value::String(dl.to_owned())); } - Ok(HandleEntry { - handle: handle.to_owned(), - component: want_str(&rec, "component")?, - file: want_str(&rec, "file")?, - line: want_i64(&rec, "line")?, - event: want_str(&rec, "event")?, - handler: want_str(&rec, "handler")?, - resource: want_str(&rec, "resource")?, - released: want_bool(&rec, "released")?, - source: want_maybe(&rec, "source")?, - source_type: want_maybe(&rec, "source_type")?, - di_source_life: want_str(&rec, "di_source_life")?, - type_name: want_maybe(&rec, "type")?, - ever_released: want_bool(&rec, "ever_released")?, - pool: want_bool(&rec, "pool")?, - }) + rec } -/// A flow-local handle record (`parg_*` carries no `pool` key; `loc_*` does). -fn flow_local_entry( - handle: &str, +/// A flow-local handle record, exactly the dict `to_module` builds: `file`, +/// `line`, `column` (`_as_col`-coerced: a 1-based int or `null`), `event`, +/// `component`, `resource: "flow-local"`, `ever_released`, and — for a `loc_*` +/// handle only, never a `parg_*` — `pool`. +fn flow_local_record( file: &str, line: i64, + column: Option<&Value>, event: &str, component: &str, ever_released: bool, pool: Option, -) -> HandleEntry { - HandleEntry { - handle: handle.to_owned(), - component: Some(component.to_owned()), - file: Some(file.to_owned()), - line: Some(line), - event: Some(event.to_owned()), - handler: None, - resource: Some("flow-local".to_owned()), - released: None, - source: Maybe::Missing, - source_type: Maybe::Missing, - di_source_life: None, - type_name: Maybe::Missing, - ever_released: Some(ever_released), - pool, +) -> Obj { + let mut rec = Obj::new(); + rec.insert("file".to_owned(), Value::String(file.to_owned())); + rec.insert("line".to_owned(), Value::from(line)); + rec.insert( + "column".to_owned(), + as_col(column).map_or(Value::Null, Value::from), + ); + rec.insert("event".to_owned(), Value::String(event.to_owned())); + rec.insert("component".to_owned(), Value::String(component.to_owned())); + rec.insert( + "resource".to_owned(), + Value::String("flow-local".to_owned()), + ); + rec.insert("ever_released".to_owned(), Value::Bool(ever_released)); + if let Some(pool) = pool { + rec.insert("pool".to_owned(), Value::Bool(pool)); } + rec } // --- DI registrations --------------------------------------------------------- @@ -1269,15 +1361,15 @@ fn lower_fn_params( f: &Obj, ffile: &str, fname: &str, - handles: &mut Vec, + handles: &mut HandleStore, loc: &mut i64, localmap: &mut HashMap, released: &HashSet, mos: &Mos, -) -> Vec { +) -> Result, BridgeError> { let mut out = Vec::new(); let Some(raw) = f.get("params").and_then(Value::as_array) else { - return out; + return Ok(out); }; let summ = mos_lookup(mos, fname, call_sig(f)); for (i, p) in raw.iter().enumerate() { @@ -1318,15 +1410,18 @@ fn lower_fn_params( *loc = loc.saturating_add(1); let line = as_int(p.get("line")); localmap.insert(cname.clone(), sym.clone()); - handles.push(flow_local_entry( + handles.push( &sym, - ffile, - line, - &cname, - fname, - released.contains(&cname), - None, - )); + flow_local_record( + ffile, + line, + p.get("column"), + &cname, + fname, + released.contains(&cname), + None, + ), + )?; out.push(Param { handle: sym, type_shape: tref, @@ -1334,7 +1429,7 @@ fn lower_fn_params( lifetime: None, }); } - out + Ok(out) } // --- flow lowering (`_lower_flow`) -------------------------------------------- @@ -1342,7 +1437,7 @@ fn lower_fn_params( struct FnCtx<'v, 'a> { ffile: &'a str, fname: &'a str, - handles: &'a mut Vec, + handles: &'a mut HandleStore, loc: &'a mut i64, localmap: &'a mut HashMap, released: &'a HashSet, @@ -1369,15 +1464,18 @@ fn lower_flow<'v>(ctx: &mut FnCtx<'v, '_>, nodes: &'v [Value]) -> Result(ctx: &mut FnCtx<'v, '_>, nodes: &'v [Value]) -> Result(ctx: &mut FnCtx<'v, '_>, nodes: &'v [Value]) -> Result(ctx: &mut FnCtx<'v, '_>, nodes: &'v [Value]) -> Result Result { + lower_full(facts).map(|l| l.doc) +} + +pub(crate) fn lower_full(facts: &OwnIr) -> Result { let root_value = facts.to_value().map_err(|e| BridgeError(e.to_string()))?; let root = root_value .as_object() .expect("a struct serializes to an object"); - let mut handles: Vec = Vec::new(); + let mut handles = HandleStore::default(); let mut functions: Vec = Vec::new(); let mut gid: i64 = 0; let mut any_capture = false; @@ -1666,13 +1774,10 @@ pub(crate) fn lower(facts: &OwnIr) -> Result { } let handle = format!("cap_{gid}"); gid = gid.saturating_add(1); - handles.push(subscription_entry( + handles.push( &handle, - sub, - &cname, - comp.get("file"), - None, - )?); + subscription_record(sub, &cname, comp.get("file"), None), + )?; let line = as_int(sub.get("line")); params.push(Param { handle: handle.clone(), @@ -1710,13 +1815,10 @@ pub(crate) fn lower(facts: &OwnIr) -> Result { let src_life = src_life.clone(); let handle = format!("cap_{gid}"); gid = gid.saturating_add(1); - handles.push(subscription_entry( + handles.push( &handle, - sub, - &cname, - comp.get("file"), - Some(&src_life), - )?); + subscription_record(sub, &cname, comp.get("file"), Some(&src_life)), + )?; let line = as_int(sub.get("line")); params.push(Param { handle: handle.clone(), @@ -1740,13 +1842,10 @@ pub(crate) fn lower(facts: &OwnIr) -> Result { // R6: the acquire/release token path. let handle = format!("sub_{gid}"); gid = gid.saturating_add(1); - handles.push(subscription_entry( + handles.push( &handle, - sub, - &cname, - comp.get("file"), - None, - )?); + subscription_record(sub, &cname, comp.get("file"), None), + )?; let Some(rtype) = resource_type(rkind) else { // #294 OD-2 landed: IR4 everywhere — a present-but-unknown kind // is fail-loud on the tolerant door too (Python `_route_resource` @@ -1782,10 +1881,20 @@ pub(crate) fn lower(facts: &OwnIr) -> Result { Some(Value::Array(a)) => a.as_slice(), _ => &[], // Python: a non-list `functions` skips the whole section }; - // D5.1: resolve interprocedural transfer once, up front; degradation to - // an empty MOS mirrors Python's exception guard (not reachable from the - // production skeleton builder, which never emits duplicate keys). - let mos_map: Mos = mos::solve(build_skeletons(raw_fns)).unwrap_or_default(); + // D5.1: resolve interprocedural transfer once, up front; a solver failure + // degrades the whole layer to the empty MOS AND records the reason for + // OWN052 (INF-F6, BR-M1) — Python's `except Exception as exc: + // notes.append(f"{type(exc).__name__}: {exc}")`; the only failure a facts + // document can reach is the duplicate-key guard, a `ValueError` there. + let mut mos_notes: Vec = Vec::new(); + let mos_map: Mos = match mos::solve(build_skeletons(raw_fns)) { + Ok(m) => m, + Err(e) => { + mos_notes.push(format!("ValueError: {e}")); + Mos::default() + } + }; + let mut advisories: Vec = Vec::new(); let fp_names: Vec = raw_fns .iter() .filter_map(Value::as_object) @@ -1821,37 +1930,67 @@ pub(crate) fn lower(facts: &OwnIr) -> Result { &mut localmap, &released, &mos_map, - ); + )?; // the optimistic default (d5 §5): a may/unknown-contract handoff // discharges at a TOP-LEVEL call (kill site) or untracks whole-body. - let unverified = unverified_arg_names(nodes, &mos_map); + let unverified = unverified_transfer_calls(nodes, &mos_map); let kill_sites = kill_sites_for_unverified(nodes, &mos_map); let untracked: HashSet = unverified - .into_iter() + .iter() + .map(|(a, _, _, _)| a.clone()) .filter(|a| !kill_sites.contains_key(a)) .collect(); + // OWN051, gated on args that actually carry an obligation here (an + // acquired local or a fresh factory result) — a plain value passed to + // a may-position is not a gap worth a note (BR-L8). + if !unverified.is_empty() { + let mut owned_here = collect_vars(nodes, "acquire", "var"); + for (v, c) in call_result_callees(nodes) { + if let Some((callee, sig)) = c { + if callee_returns_fresh(&callee, &mos_map, &first_party, sig.as_deref()) { + owned_here.insert(v); + } + } + } + for (arg, callee, transfer, cline) in &unverified { + if !owned_here.contains(arg) { + continue; + } + advisories.push(Own051 { + file: ffile.clone(), + line: *cline, + component: fname.clone(), + arg: arg.clone(), + callee: callee.clone(), + transfer, + }); + } + } // cross-branch locals declared once at the outer scope; an untracked // local must NOT be hoisted (it would re-mint the removed obligation). - let hoist: BTreeMap = + let hoist: BTreeMap, bool)> = hoisted_branch_locals(nodes, &mos_map, &first_party) .into_iter() .filter(|(k, _)| !untracked.contains(k)) .collect(); let hoisted_set: BTreeSet = hoist.keys().cloned().collect(); let mut fbody: Vec = Vec::new(); - for (hname, (hline, hpool)) in &hoist { + for (hname, (hline, hcol, hpool)) in &hoist { let hh = format!("loc_{loc}"); loc = loc.saturating_add(1); localmap.insert(hname.clone(), hh.clone()); - handles.push(flow_local_entry( + handles.push( &hh, - &ffile, - *hline, - hname, - &fname, - released.contains(hname), - Some(*hpool), - )); + flow_local_record( + &ffile, + *hline, + hcol.map(Value::from).as_ref(), + hname, + &fname, + released.contains(hname), + Some(*hpool), + ), + )?; fbody.push(Stmt::Acquire { handle: hh, resource: "Disposable".to_owned(), @@ -1892,17 +2031,22 @@ pub(crate) fn lower(facts: &OwnIr) -> Result { let module = root .get("module") .map_or_else(|| "Extracted".to_owned(), py_str); - Ok(LoweredDocument { - lowered_version: LOWERED_VERSION, - module, - resources: prelude_resources(), - externs: sink_externs(), - lifetimes: if any_capture { - capture_lifetimes() - } else { - Vec::new() + Ok(Lowering { + doc: LoweredDocument { + lowered_version: LOWERED_VERSION, + module, + resources: prelude_resources(), + externs: sink_externs(), + lifetimes: if any_capture { + capture_lifetimes() + } else { + Vec::new() + }, + functions, + handles: handles.entries, }, - functions, - handles, + handles: handles.records, + mos_notes, + advisories, }) } diff --git a/rust/crates/own-bridge/src/verdict.rs b/rust/crates/own-bridge/src/verdict.rs new file mode 100644 index 00000000..4d48c1cb --- /dev/null +++ b/rust/crates/own-bridge/src/verdict.rs @@ -0,0 +1,719 @@ +//! The verdict mapping — `ownlang/ownir.py::check_facts` (spec/Bridge.md §5), +//! at the #259 checkpoint-4 surface: every finding's **identity and anchor** +//! (`file`, `line`, `column`, `code`, `component`, `event`, `handler`), its +//! `kind`, its tiering (`advisory`, `severity`) and its suppression +//! (`ignore_reason`) — everything the reference's dedup key and sort key are +//! made of, except the human `message`. Message synthesis (BR-V4), the +//! `related`/`flow` evidence slices and the rendered surfaces are checkpoint 5. +//! +//! 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). +//! +//! **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. +//! +//! **Dedup key, minus the message.** The reference deduplicates on +//! `(file, line, column, code, component, event, handler, message, kind, +//! advisory, severity, ignore_reason)`. This checkpoint carries every member +//! but `message`. That is not a weakening on the reference's own outputs: +//! every message is a function of the finding's handle record and code (the +//! flow-local wordings key on `code`/`pool`/`ever_released`, the token +//! wordings on the record, and the same-handle same-code duplicates BR-V7 +//! exists for are byte-identical), so two findings equal on the carried key +//! are equal on the message too. The fixture replay measures it on the whole +//! corpus; checkpoint 5 adds the member itself. + +// The mapping mirrors `check_facts` branch-for-branch; `expect()` marks +// invariant-backed reads (a value the same function just inserted). +// `redundant_pub_crate` (nursery) conflicts with the workspace's DENY of +// `unreachable_pub` for items in private modules; pub(crate) is the honest +// visibility here (same stance as `mos.rs`). +#![allow( + clippy::too_many_lines, + clippy::expect_used, + clippy::redundant_pub_crate +)] + +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_diagnostics::{Diagnostic, Severity}; +use own_ir::OwnIr; +use serde_json::Value; +use std::collections::HashSet; + +/// One bridge finding — the Rust twin of `ownir.Finding` at the checkpoint-4 +/// surface (the `message`, `related` and `flow` members land with checkpoint 5). +/// +/// Field semantics are the reference's: `line` is the C# anchor (the fact's +/// own line — `0` when the fact carries none, as for the anchorless OWN052); +/// `column` is the fact's 1-based column or `None`, never invented; `kind` is +/// the `[resource: ]` tag; `advisory` marks a coverage note (OWN050/ +/// 051/052) that never fails a build; `severity` is the intrinsic +/// `"warning"` tier of an unprovable-lifetime subscription or a DI002–005 +/// verdict, `None` for a provable leak shown at the host's severity; +/// `ignore_reason` is the `[OwnIgnore("…")]` justification of a suppressed +/// (still counted) finding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + pub file: String, + pub line: i64, + pub column: Option, + pub code: String, + pub component: String, + pub event: String, + pub handler: String, + pub kind: String, + pub advisory: bool, + pub severity: Option, + pub ignore_reason: Option, +} + +impl Finding { + fn new(file: impl Into, line: i64, code: &str, kind: &str) -> Self { + Self { + file: file.into(), + line, + column: None, + code: code.to_owned(), + component: String::new(), + event: String::new(), + handler: String::new(), + kind: kind.to_owned(), + advisory: false, + severity: None, + ignore_reason: None, + } + } +} + +/// BR-V2: the closed list of bridge-artifact core codes dropped before mapping. +const SKIP: [&str; 5] = ["OWN033", "OWN034", "OWN035", "OWN040", "OWN041"]; + +/// Python `str(v)` for the JSON scalars a record field can hold (the +/// containers a scalar field cannot hold on either door are rendered as +/// JSON text — the same boundary `lower.rs` states for `py_str`). +fn py_str(v: &Value) -> String { + lower::py_str(v) +} + +/// `rec.get(key, default)` stringified — a PRESENT value of any type reads +/// through `str()`, only an absent key takes the default. +fn get_or(rec: &Obj, key: &str, default: &str) -> String { + rec.get(key).map_or_else(|| default.to_owned(), py_str) +} + +fn get_str<'a>(rec: &'a Obj, key: &str) -> Option<&'a str> { + rec.get(key).and_then(Value::as_str) +} + +/// `_as_int`: a non-bool integer or `0`. +fn as_int(v: Option<&Value>) -> i64 { + v.and_then(Value::as_i64).unwrap_or(0) +} + +/// Python truthiness of a present value (absent = falsy). +fn truthy(v: Option<&Value>) -> bool { + match v { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(Value::Number(n)) => n.as_f64().is_some_and(|f| f != 0.0), + Some(Value::String(s)) => !s.is_empty(), + Some(Value::Array(a)) => !a.is_empty(), + Some(Value::Object(o)) => !o.is_empty(), + } +} + +/// Python `{x!r}` for the values the map-or-raise message interpolates: a +/// subject is `None` or a simple string, repr'd with single quotes. +fn py_repr(v: Option<&str>) -> String { + v.map_or_else(|| "None".to_owned(), |s| format!("'{s}'")) +} + +/// `_route_resource(rkind)[1]`: the `[resource: …]` label of an owned kind — +/// fail-loud on an unknown one, like the reference (IR4 everywhere). +fn route_kind(rkind: &str) -> Result<&'static str, BridgeError> { + match rkind { + "subscription" | "subscribe" => Ok("subscription token"), + "timer" => Ok("timer"), + "disposable" => Ok("disposable field"), + "local-disposable" => Ok("disposable"), + "pool" => Ok("pooled buffer"), + other => Err(BridgeError(format!( + "unknown resource kind '{other}' — a new kind is a vocabulary change \ + that must bump OWNIR_VERSION (see spec/OwnIR.md §2)" + ))), + } +} + +/// `_handle_of(diag)`: the handle a core verdict is about, from its +/// structured `subject` (`name#line`) — never from the message text. +fn handle_of(d: &Diagnostic) -> Option<&str> { + d.subject + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| s.split('#').next().unwrap_or(s)) +} + +/// BR-V3: the reference's `OwnIRError` text for a core verdict no handle +/// claims. The `message=` member interpolates the core diagnostic's message, +/// which this core still carries as its TITLE (message text is checkpoint 5) — +/// the verdict replay compares this rejection on the class, up to that member. +fn cannot_map(d: &Diagnostic) -> BridgeError { + BridgeError(format!( + "internal: the core reported [{}] on the lowered facts that the bridge \ + cannot map back to a C# subscription (subject={}, message={}). The OwnIR \ + lowering has drifted from the core; teach the bridge this diagnostic \ + rather than dropping the finding.", + d.code, + py_repr(d.subject.as_deref()), + py_repr(Some(&d.message)), + )) +} + +/// BR-V1/BR-V2, as one pure predicate: a core diagnostic is mapped iff it is +/// an ERROR-severity verdict (a sub-error core diagnostic is not a verdict) +/// and its code is not on the closed bridge-artifact list. +/// +/// The severity half cannot be reached from a facts document today — no +/// producer feeds the one core pass that grades below ERROR (the buffer-policy +/// warnings) — so it is proven on a synthetic diagnostic in this module's +/// tests rather than left as a rule the corpus can never fail. +fn is_mapped(d: &Diagnostic) -> bool { + d.severity == Severity::Error && !SKIP.contains(&d.code.as_str()) +} + +/// The core half of BR-V1: map each mapped core diagnostic ([`is_mapped`]) +/// through its handle record to a finding — anchor, kind and tier per +/// BR-V4/V5/V6. +fn map_core( + diags: &[Diagnostic], + records: &std::collections::HashMap, +) -> Result, BridgeError> { + let mut out = Vec::new(); + for d in diags.iter().filter(|d| is_mapped(d)) { + let Some(rec) = handle_of(d).and_then(|h| records.get(h)) else { + return Err(cannot_map(d)); + }; + let event = get_or(rec, "event", "?"); + let handler = get_or(rec, "handler", "?"); + let component = rec + .get("component") + .map(py_str) + .expect("every handle record carries its component"); + let file = rec + .get("file") + .map(py_str) + .expect("every handle record carries its file"); + let rkind = get_str(rec, "resource").unwrap_or("subscription"); + // `sub.get("ignore_reason") or None`: an empty string never suppresses. + let ir = rec + .get("ignore_reason") + .filter(|v| truthy(Some(v))) + .map(py_str); + let anchor = as_int(rec.get("line")); + let column = as_col(rec.get("column")); + if rkind == "flow-local" { + let pool = truthy(rec.get("pool")); + if d.code == "OWN025" { + // the VIEW site (the core's line), never the acquire's column. + let mut f = Finding::new(file, i64::from(d.line), "OWN025", "pooled buffer"); + f.component = component; + f.event = event; + out.push(f); + continue; + } + let mut f = Finding::new( + file, + anchor, + &d.code, + if pool { "pooled buffer" } else { "disposable" }, + ); + f.column = column; + f.component = component; + f.event = event; + out.push(f); + continue; + } + if truthy(rec.get("di_source_life")) || rkind == "capture" { + // OWN014 region escape: DI-sourced or a static capture — error-tier. + let mut f = Finding::new(file, anchor, &d.code, "subscription token"); + f.column = column; + f.component = component; + f.event = event; + f.handler = handler; + f.ignore_reason = ir; + out.push(f); + continue; + } + let kind = route_kind(rkind)?; + // P-004 tiering: only the plain `+=` subscription and the ignored + // `.Subscribe()` result grade on the source's proven lifetime; every + // other kind is a provable leak at the host's severity. + let injected = get_str(rec, "source") == Some("injected"); + let severity = match rkind { + "timer" | "disposable" | "local-disposable" | "pool" => None, + _ if injected => Some("warning".to_owned()), + _ => None, + }; + let mut f = Finding::new(file, anchor, &d.code, kind); + f.column = column; + f.component = component; + f.event = event; + f.handler = handler; + f.severity = severity; + f.ignore_reason = ir; + out.push(f); + } + Ok(out) +} + +/// A coordinate handed to `own-analysis` as an anchor: `u32` or refuse (the +/// same declared boundary as the AST lines, `ast::core_line`). +fn anchor_line(line: i64, what: &str) -> Result { + ast::core_line(line, what) +} + +/// A coordinate whose only reader guards on `>= 1` (a DI call/store site, an +/// effect binding's declaration line): a negative value behaves exactly like +/// `0` on every path, so it is folded to `0`; above the domain it would BE +/// read, so that side stays fail-loud. +fn guarded_line(line: i64, what: &str) -> Result { + if line < 0 { + Ok(0) + } else { + ast::core_line(line, what) + } +} + +/// `tuple(s.get(key, []))` for the string arrays the strict door types: a +/// present non-array is refused (the reference would char-split a string or +/// crash on anything else — accidental behavior the tolerant door does not +/// promise; #294 OD-1). +fn str_array(s: &Obj, key: &str, what: &str) -> Result, BridgeError> { + match s.get(key) { + None => Ok(Vec::new()), + Some(Value::Array(items)) => Ok(items.iter().map(py_str).collect()), + Some(other) => Err(BridgeError(format!( + "{what} '{key}' must be a JSON array of strings on this door, got {other}" + ))), + } +} + +/// `_resolve_sites`: `(type, file, line)` per dict entry; a non-list reads as +/// empty, a non-dict entry is skipped. +fn sites(raw: Option<&Value>, what: &str) -> Result, BridgeError> { + let Some(Value::Array(items)) = raw else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + for x in items.iter().filter_map(Value::as_object) { + out.push(( + get_or(x, "type", ""), + get_or(x, "file", "?"), + guarded_line(as_int(x.get("line")), what)?, + )); + } + Ok(out) +} + +/// BR-P1: `services[]` → `di.Service` values with the reference's coercions, +/// then the five finders (owned by `own-analysis`) in the bridge's append +/// order, each finding at the analysis-selected primary anchor. +fn di_findings(root: &Obj) -> Result, BridgeError> { + let Some(Value::Array(raw)) = root.get("services") else { + return Ok(Vec::new()); + }; + let mut services = Vec::new(); + for s in raw.iter().filter_map(Value::as_object) { + let name = get_or(s, "name", "?"); + let what = format!("service '{name}'"); + services.push(Service { + lifetime: di::Lifetime::parse(&get_or(s, "lifetime", "")), + deps: str_array(s, "deps", &what)?, + weak_deps: str_array(s, "weak_deps", &what)?, + root_resolves: str_array(s, "root_resolves", &what)?, + // only the JSON boolean `true` counts. + disposable: s.get("disposable") == Some(&Value::Bool(true)), + file: get_or(s, "file", "?"), + line: anchor_line(as_int(s.get("line")), &what)?, + root_resolve_sites: sites( + s.get("root_resolve_sites"), + &format!("{what} root_resolve_sites"), + )?, + scope_cached: str_array(s, "scope_cached", &what)?, + scope_cache_sites: sites( + s.get("scope_cache_sites"), + &format!("{what} scope_cache_sites"), + )?, + name, + }); + } + Ok(di::all_di_findings(&services) + .into_iter() + .map(|c| { + let mut f = Finding::new(c.file, i64::from(c.line), c.code, "DI lifetime"); + f.component = c.singleton; + f.event = c.subject; + // DI003/002/004/005 are real verdicts shown at `warning`; DI001 is + // the umbrella captive at the host's severity. + if c.code != "DI001" { + f.severity = Some("warning".to_owned()); + } + f + }) + .collect()) +} + +/// BR-P2/BR-D2: `effects[]` re-validated skip-not-coerce, then +/// `find_effect_storms` (owned by `own-analysis`) at the effect's own site. +fn effect_findings(root: &Obj) -> Result, BridgeError> { + let Some(Value::Array(raw)) = root.get("effects") else { + return Ok(Vec::new()); + }; + let mut effects = Vec::new(); + 'entries: for e in raw.iter().filter_map(Value::as_object) { + let deps: Vec = match e.get("deps") { + None => Vec::new(), + Some(Value::Array(items)) if items.iter().all(Value::is_string) => items + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + Some(_) => continue, + }; + let io = match e.get("io") { + None => false, + Some(Value::Bool(b)) => *b, + Some(_) => continue, + }; + let binds_raw: &[Value] = match e.get("bindings") { + None => &[], + Some(Value::Array(items)) => items.as_slice(), + Some(_) => continue, + }; + let mut bindings = Vec::new(); + for b in binds_raw { + let Some(b) = b.as_object() else { + continue 'entries; + }; + let name_ok = b.get("name").map_or(true, Value::is_string); + let init_ok = b.get("init").map_or(true, Value::is_string); + let refs: Vec = match b.get("refs") { + None => Vec::new(), + Some(Value::Array(items)) if items.iter().all(Value::is_string) => items + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + Some(_) => continue 'entries, + }; + if !(name_ok && init_ok) { + continue 'entries; + } + bindings.push(Binding { + name: get_or(b, "name", "?"), + init: get_or(b, "init", "unknown"), + refs, + line: guarded_line(as_int(b.get("line")), "effect binding")?, + }); + } + let component = get_or(e, "component", "?"); + effects.push(Effect { + deps, + io, + bindings, + file: get_or(e, "file", "?"), + line: anchor_line(as_int(e.get("line")), &format!("effect in '{component}'"))?, + component, + }); + } + Ok(effect::find_effect_storms(&effects) + .into_iter() + .map(|s| { + let mut f = Finding::new(s.file, i64::from(s.line), "EFF001", "react effect"); + f.component = s.component; + f.event = s.dep; + f + }) + .collect()) +} + +/// `_unresolved_findings`: every `unresolved-subscription` marker as an +/// advisory OWN050 (never lowered, never a leak). +fn unresolved_findings(root: &Obj) -> Vec { + let mut out = Vec::new(); + let Some(Value::Array(comps)) = root.get("components") else { + return out; + }; + for comp in comps.iter().filter_map(Value::as_object) { + let cfile = get_or(comp, "file", "?"); + let cname = get_or(comp, "name", "?"); + let Some(Value::Array(subs)) = comp.get("subscriptions") else { + continue; + }; + for sub in subs.iter().filter_map(Value::as_object) { + if get_str(sub, "resource") != Some("unresolved-subscription") { + continue; + } + let mut f = Finding::new( + cfile.clone(), + as_int(sub.get("line")), + "OWN050", + "unresolved reference", + ); + f.column = as_col(sub.get("column")); + f.component.clone_from(&cname); + f.event = get_or(sub, "event", "?"); + f.handler = get_or(sub, "handler", "?"); + f.advisory = true; + out.push(f); + } + } + out +} + +fn transfer_note(a: &Own051) -> Finding { + let mut f = Finding::new(a.file.clone(), a.line, "OWN051", "ownership transfer"); + f.component.clone_from(&a.component); + f.event.clone_from(&a.arg); + f.handler.clone_from(&a.callee); + f.advisory = true; + f +} + +/// The dedup key (BR-V7) at this checkpoint — see the module docs for the +/// one member (`message`) it does not yet carry, and why that is exact here. +type DedupKey = ( + String, + i64, + Option, + String, + String, + String, + String, + String, + bool, + Option, + Option, +); + +fn dedup_key(f: &Finding) -> DedupKey { + ( + f.file.clone(), + f.line, + f.column, + f.code.clone(), + f.component.clone(), + f.event.clone(), + f.handler.clone(), + f.kind.clone(), + f.advisory, + f.severity.clone(), + f.ignore_reason.clone(), + ) +} + +/// The 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(()), + } +} + +/// The port of `check_facts` at the checkpoint-4 surface (see the module docs). +pub(crate) fn check_facts(facts: &OwnIr) -> Result, BridgeError> { + let root_value = facts.to_value().map_err(|e| BridgeError(e.to_string()))?; + 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); + + let mut findings = map_core(&diags, &lowering.handles)?; + findings.extend(di_findings(root)?); + findings.extend(effect_findings(root)?); + // protocol findings would append here (refused above until wired). + findings.extend(unresolved_findings(root)); + findings.extend(lowering.advisories.iter().map(transfer_note)); + let module_name = root.get("module").map_or_else(|| "?".to_owned(), py_str); + for _reason in &lowering.mos_notes { + // anchorless by nature: file-level, module-scoped (BR-V5). + let mut f = Finding::new("?", 0, "OWN052", "method summaries"); + f.component.clone_from(&module_name); + f.advisory = true; + findings.push(f); + } + + // BR-V7: first occurrence wins. + let mut seen: HashSet = HashSet::new(); + findings.retain(|f| seen.insert(dedup_key(f))); + // BR-V8: a STABLE sort — ties keep insertion order; an absent column + // sorts as 0, before every real (>= 1) one, and is never emitted as such. + findings.sort_by(|a, b| { + a.file + .cmp(&b.file) + .then_with(|| a.line.cmp(&b.line)) + .then_with(|| a.column.unwrap_or(0).cmp(&b.column.unwrap_or(0))) + .then_with(|| a.code.cmp(&b.code)) + }); + Ok(findings) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)] +mod tests { + use super::{di_findings, effect_findings, map_core, Obj}; + use own_diagnostics::{Diagnostic, Severity}; + use serde_json::{json, Value}; + use std::collections::HashMap; + + fn obj(v: &Value) -> Obj { + v.as_object().cloned().unwrap() + } + + /// BR-V1: only ERROR-severity core diagnostics are mapped. The production + /// path cannot produce a sub-error core verdict today (no facts producer + /// reaches the buffer-policy pass that grades below ERROR), so the rule is + /// proven on a synthetic WARNING driven through `map_core` itself: the + /// same diagnostic, same handle, ERROR → one finding, WARNING → none. + /// The closed BR-V2 list rides the same predicate: a skipped artifact code + /// with no subject must be dropped, never reach map-or-raise. + #[test] + fn only_error_severity_core_verdicts_are_mapped() { + let mut records: HashMap = HashMap::new(); + records.insert( + "sub_0".to_owned(), + obj(&json!({ + "event": "bus.E", "handler": "OnE", "line": 7, "released": false, + "component": "Vm", "file": "Vm.cs" + })), + ); + let error = Diagnostic::new("OWN001", "leak", 7) + .unwrap() + .with_subject("sub_0#7"); + let warning = error.clone().with_severity(Severity::Warning); + let artifact = Diagnostic::new("OWN033", "return type", 0).unwrap(); + + let mapped = map_core(&[error], &records).unwrap(); + assert_eq!( + mapped + .iter() + .map(|f| (f.file.as_str(), f.line, f.code.as_str())) + .collect::>(), + vec![("Vm.cs", 7, "OWN001")] + ); + assert!( + map_core(&[warning], &records).unwrap().is_empty(), + "a sub-error core diagnostic is not a verdict (BR-V1)" + ); + assert!( + map_core(&[artifact], &records).unwrap().is_empty(), + "a BR-V2 artifact is dropped before map-or-raise, subject or not" + ); + } + + /// BR-D2 tolerance (1): a malformed effect entry is SKIPPED as a whole, + /// never coerced into a spurious verdict. Pinned at the raw-document level + /// on purpose: the crate's public entry is the typed `OwnIr` constructor, + /// which refuses these shapes before this rule can run (#294 OD-1 — the + /// verdict ledger's `door` exclusions measure exactly that), so the + /// production surface cannot reach the rule and this is the only place + /// the port can be shown to match the reference line for line. + #[test] + fn malformed_effect_entries_are_skipped_not_coerced() { + let root = obj(&json!({ + "effects": [ + // deps must be a list of strings: `"a"` must not become `["a"]`. + {"component": "X", "file": "X.tsx", "line": 1, "io": true, "deps": "a", + "bindings": [{"name": "a", "init": "object", "refs": [], "line": 1}]}, + // io must be a bool. + {"component": "Y", "file": "Y.tsx", "line": 2, "io": "yes", "deps": ["o"], + "bindings": [{"name": "o", "init": "object", "refs": [], "line": 1}]}, + // a binding's refs must be a list of strings. + {"component": "Z", "file": "Z.tsx", "line": 3, "io": true, "deps": ["o"], + "bindings": [{"name": "o", "init": "object", "refs": "o", "line": 1}]}, + // bindings must be a list. + {"component": "W", "file": "W.tsx", "line": 4, "io": true, "deps": ["o"], + "bindings": "nope"}, + // a binding must be an object. + {"component": "V", "file": "V.tsx", "line": 5, "io": true, "deps": ["o"], + "bindings": ["o"]}, + "not-an-object", + {"component": "Ok", "file": "Ok.tsx", "line": 9, "io": true, "deps": ["o"], + "bindings": [{"name": "o", "init": "object", "refs": [], "line": 8}]} + ] + })); + let got: Vec<(String, i64)> = effect_findings(&root) + .unwrap() + .into_iter() + .map(|f| (f.file, f.line)) + .collect(); + assert_eq!(got, vec![("Ok.tsx".to_owned(), 9)]); + } + + /// A non-list `effects` block reads as no effects (never a crash). + #[test] + fn effects_block_that_is_not_a_list_yields_nothing() { + let root = obj(&json!({"effects": "nope"})); + assert!(effect_findings(&root).unwrap().is_empty()); + } + + /// 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] + fn di_coercions_match_the_reference() { + let root = obj(&json!({ + "services": [ + {"name": "App", "lifetime": "singleton", "file": "reg.cs", "line": 5, + "deps": ["Conn", "Mystery", "Db"]}, + {"name": "Conn", "lifetime": "transient", "file": "reg.cs", "line": 6, + "deps": [], "disposable": "true"}, + {"name": "Mystery", "lifetime": "prototype", "file": "reg.cs", "line": 7, + "deps": []}, + {"name": "Db", "lifetime": "scoped", "file": "reg.cs", "line": 8, "deps": []}, + 42 + ] + })); + let got: Vec<(String, i64, String)> = di_findings(&root) + .unwrap() + .into_iter() + .map(|f| (f.file, f.line, f.code)) + .collect(); + assert_eq!(got, vec![("reg.cs".to_owned(), 5, "DI001".to_owned())]); + } + + /// A present non-array dependency list is refused on this door (the + /// reference would char-split a string or crash — accidental, #294 OD-1). + #[test] + fn non_array_deps_are_refused_loudly() { + let root = obj(&json!({ + "services": [{"name": "App", "lifetime": "singleton", "deps": "Db"}] + })); + let err = di_findings(&root).unwrap_err(); + assert!( + err.to_string().contains("'deps' must be a JSON array"), + "{err}" + ); + } +} diff --git a/rust/crates/own-bridge/tests/verdicts.rs b/rust/crates/own-bridge/tests/verdicts.rs new file mode 100644 index 00000000..7adbda61 --- /dev/null +++ b/rust/crates/own-bridge/tests/verdicts.rs @@ -0,0 +1,453 @@ +//! The checkpoint-4 acceptance contract (#259): for every case of the Layer 3 +//! verdict fixture family, +//! +//! ```text +//! facts.json → OwnIr (typed constructor, the tolerant entry) → own_bridge::check_facts +//! ≡ .verdicts.json on (file, line, column, code, component, +//! event, handler, kind, advisory, +//! severity, ignore_reason) +//! ``` +//! +//! The golden is the reference's COMPLETE `Finding` list (`ownlang/verdicts.py`, +//! regenerate: `python tests/test_verdict_fixtures.py --write`); this replay +//! declares the members it compares — identity, anchor, kind and tiering. The +//! two it does not, `message` and the evidence slices (`related`/`flow`), are +//! checkpoint 5's, and the golden carries them already so cp5 tightens this +//! comparison without regenerating anything. +//! +//! Refusals compare on the reference's error text — byte-exact for a +//! lowering-time refusal (vocabulary skew, an unknown resource kind), and up +//! to the `message=` member for the map-or-raise class (BR-V3), whose text +//! interpolates the core diagnostic's message that this core still carries as +//! its title. That normalization is the one declared cp4 comparison boundary +//! on a refusal, and it is applied to BOTH sides. +//! +//! Independently enforced here (not outsourced to Python): +//! * ledger/tree equality — the swept corpora + the synthetic manifest cases +//! == the goldens on disk, with unique names across all sources; +//! * the exclusion ledger is EXECUTABLE: every `rust_replay_excluded` case is +//! run and must be refused exactly as declared (at the typed door, or by +//! `check_facts` with the declared error text) — an exclusion that stops +//! holding is a red build demanding promotion, never a silent coverage hole; +//! * the exclusion set is pinned by name — a new declared boundary is a +//! deliberate contract decision, not a drive-by; +//! * every replayed case is deterministic (same facts, same list, twice). + +#![allow(clippy::panic, clippy::expect_used, clippy::unwrap_used)] + +use own_bridge::Finding; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +const FIXDIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/verdicts" +); +const CORPORA: [(&str, &str); 3] = [ + ( + "ownir", + concat!(env!("CARGO_MANIFEST_DIR"), "/../../../tests/fixtures/ownir"), + ), + ( + "lowered", + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/lowered" + ), + ), + ( + "summaries", + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/summaries" + ), + ), +]; + +/// The verdict-family ledger (strict: an unknown field is a contract change +/// this suite must be taught, not skip). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + #[allow(dead_code)] + comment: String, + verdicts_version: u32, + rust_replay_excluded: Vec, + cases: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Exclusion { + name: String, + reason: String, + rust_refusal: Refusal, + #[serde(default)] + rust_error_contains: Option, +} + +#[derive(Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +#[serde(rename_all = "lowercase")] +enum Refusal { + /// The typed `OwnIr` constructor refuses the document (#294 OD-1). + Door, + /// `check_facts` returns an error (a declared bridge boundary). + Bridge, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + name: String, + rules: Vec, +} + +/// One golden: the reference's complete finding list, or its refusal text. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Golden { + verdicts_version: u32, + #[serde(default)] + error: Option, + #[serde(default)] + findings: Option>, +} + +/// Every `ownir.Finding` member, strictly — a member added on the Python side +/// goes red here until this replay is taught it (and decides whether to +/// compare it). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] // `message`/`related`/`flow` are parsed (shape-locked), compared at cp5 +struct GoldenFinding { + file: String, + line: i64, + code: String, + component: String, + event: String, + handler: String, + message: String, + kind: String, + advisory: bool, + severity: Option, + related: Vec>, + flow: Vec>, + ignore_reason: Option, + column: Option, +} + +/// The checkpoint-4 comparison key. +type Key = ( + String, + i64, + Option, + String, + String, + String, + String, + String, + bool, + Option, + Option, +); + +fn key_of_golden(f: &GoldenFinding) -> Key { + ( + f.file.clone(), + f.line, + f.column, + f.code.clone(), + f.component.clone(), + f.event.clone(), + f.handler.clone(), + f.kind.clone(), + f.advisory, + f.severity.clone(), + f.ignore_reason.clone(), + ) +} + +fn key_of_rust(f: &Finding) -> Key { + ( + f.file.clone(), + f.line, + f.column, + f.code.clone(), + f.component.clone(), + f.event.clone(), + f.handler.clone(), + f.kind.clone(), + f.advisory, + f.severity.clone(), + f.ignore_reason.clone(), + ) +} + +/// The refusal comparison class (see the module docs): the map-or-raise text +/// up to its `message=` member; every other refusal in full. +fn refusal_class(text: &str) -> String { + const MAP_OR_RAISE: &str = "internal: the core reported ["; + if text.starts_with(MAP_OR_RAISE) { + text.split(", message=").next().unwrap_or(text).to_owned() + } else { + text.to_owned() + } +} + +fn read(path: &str) -> String { + std::fs::read_to_string(path).unwrap_or_else(|e| { + panic!( + "cannot read {path}: {e} — regenerate: python tests/test_verdict_fixtures.py --write" + ) + }) +} + +fn stems(dir: &str, suffix: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + for entry in std::fs::read_dir(dir).expect("fixture directory is readable") { + let file = entry.expect("directory entry").file_name(); + let file = file.to_str().expect("fixture filenames are UTF-8"); + if let Some(stem) = file.strip_suffix(suffix) { + out.insert(stem.to_owned()); + } + } + out +} + +/// The full plan: case name → facts path (the swept corpora + the synthetic +/// cases), with unique names enforced across every source. +fn plan(manifest: &Manifest) -> BTreeMap { + let mut plan: BTreeMap = BTreeMap::new(); + let mut origin: BTreeMap = BTreeMap::new(); + for (label, dir) in CORPORA { + for name in stems(dir, ".facts.json") { + assert!( + !plan.contains_key(&name), + "case name '{name}' exists in BOTH the {} and {label} corpora", + origin.get(&name).copied().unwrap_or("?") + ); + plan.insert(name.clone(), format!("{dir}/{name}.facts.json")); + origin.insert(name, label); + } + } + let mut synthetic = BTreeSet::new(); + for c in &manifest.cases { + assert!( + !c.rules.is_empty(), + "case '{}' must name the BR rules it pins", + c.name + ); + assert!( + synthetic.insert(c.name.clone()), + "duplicate manifest case name: {}", + c.name + ); + assert!( + !plan.contains_key(&c.name), + "synthetic case '{}' shadows a swept corpus case name", + c.name + ); + } + assert_eq!( + synthetic, + stems(FIXDIR, ".facts.json"), + "manifest case names != *.facts.json under fixtures/verdicts" + ); + for name in synthetic { + plan.insert(name.clone(), format!("{FIXDIR}/{name}.facts.json")); + } + plan +} + +/// The tolerant entry: the typed constructor, WITHOUT the strict door +/// (`OwnIr::from_json`) — the same choice `tests/replay.rs` makes, for the +/// same reason: `check_facts` is the tolerant door in the reference, and the +/// map-or-raise and unknown-kind cases must reach the bridge to be measured. +fn construct(facts_text: &str) -> Result { + serde_json::from_str(facts_text) +} + +/// The exclusion ledger: pinned by name, and EXECUTABLE — every entry is run +/// and must be refused exactly as declared. Returns the excluded entries. +fn assert_exclusions_hold<'m>( + manifest: &'m Manifest, + plan: &BTreeMap, +) -> BTreeMap { + let mut excluded: BTreeMap = BTreeMap::new(); + for e in &manifest.rust_replay_excluded { + assert!( + !e.reason.is_empty(), + "exclusion '{}' must carry its reason", + e.name + ); + assert!( + plan.contains_key(&e.name), + "rust_replay_excluded names '{}', which is not a planned case", + e.name + ); + assert!( + excluded.insert(e.name.clone(), e).is_none(), + "rust_replay_excluded lists '{}' twice", + e.name + ); + } + 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", + "verdict_boundary_effect_line_negative", + "verdict_door_effect_deps_not_strings", + "verdict_door_service_unknown_lifetime", + ] + .into_iter() + .collect(); + assert_eq!( + excluded.keys().map(String::as_str).collect::>(), + expected_exclusions, + "the exclusion ledger changed — a new declared boundary (or a promotion) is a \ + deliberate contract decision recorded in the checkpoint note, not a drive-by" + ); + for (name, e) in &excluded { + let facts_text = read(plan.get(name).expect("an exclusion names a planned case")); + match e.rust_refusal { + Refusal::Door => { + assert!( + construct(&facts_text).is_err(), + "{name}: declared a typed-door refusal, but the constructor now ACCEPTS \ + the document — the exclusion has rotted; promote the case (remove it \ + from rust_replay_excluded)" + ); + } + Refusal::Bridge => { + let facts = construct(&facts_text).unwrap_or_else(|err| { + panic!("{name}: a bridge-refused case must construct: {err}") + }); + let err = own_bridge::check_facts(&facts).err().unwrap_or_else(|| { + panic!( + "{name}: declared a bridge refusal, but check_facts now SUCCEEDS — the \ + exclusion has rotted; promote the case" + ) + }); + if let Some(needle) = &e.rust_error_contains { + assert!( + err.to_string().contains(needle), + "{name}: refused, but not for the declared reason: expected {needle:?} \ + in {err}" + ); + } + } + } + } + excluded +} + +/// Replay one case against its golden: `Ok((refused, finding count))`, or the +/// divergence description. +fn replay_case(name: &str, facts_path: &str) -> Result<(bool, usize), String> { + let golden: Golden = serde_json::from_str(&read(&format!("{FIXDIR}/{name}.verdicts.json"))) + .unwrap_or_else(|e| panic!("{name}: golden does not parse (typed, strict): {e}")); + assert_eq!(golden.verdicts_version, 1, "{name}: golden surface version"); + let facts = construct(&read(facts_path)).unwrap_or_else(|e| { + panic!("{name}: the typed door refused a case that is not in the exclusion ledger: {e}") + }); + let first = own_bridge::check_facts(&facts); + let second = own_bridge::check_facts(&facts); + assert_eq!( + first + .as_ref() + .map(|v| v.iter().map(key_of_rust).collect::>()), + second + .as_ref() + .map(|v| v.iter().map(key_of_rust).collect::>()), + "{name}: check_facts is not deterministic" + ); + match (first, golden.error, golden.findings) { + (Err(err), Some(text), _) => { + let (got, want) = (refusal_class(&err.to_string()), refusal_class(&text)); + if got == want { + Ok((true, 0)) + } else { + Err(format!( + "{name}: refusal class differs\n python = {want}\n rust = {got}" + )) + } + } + (Ok(list), None, Some(want)) => { + let got: Vec = list.iter().map(key_of_rust).collect(); + let want: Vec = want.iter().map(key_of_golden).collect(); + if got == want { + Ok((false, want.len())) + } else { + Err(format!( + "{name}: verdict list differs\n python = {want:#?}\n rust = {got:#?}" + )) + } + } + (Ok(list), Some(text), _) => Err(format!( + "{name}: the reference REFUSES ({text}) but Rust returned {} finding(s)", + list.len() + )), + (Err(err), None, _) => Err(format!( + "{name}: the reference returns findings but Rust REFUSED: {err}" + )), + (Ok(_), None, None) => panic!("{name}: a golden must carry findings or an error"), + } +} + +#[test] +fn replays_every_case_to_its_golden() { + let manifest: Manifest = serde_json::from_str(&read(&format!("{FIXDIR}/manifest.json"))) + .expect("manifest.json parses (typed, strict)"); + assert_eq!( + manifest.verdicts_version, 1, + "manifest verdicts_version must match this replay's surface version" + ); + let plan = plan(&manifest); + let planned: BTreeSet = plan.keys().cloned().collect(); + assert_eq!( + planned, + stems(FIXDIR, ".verdicts.json"), + "planned cases != *.verdicts.json goldens on disk (missing or orphaned golden)" + ); + let excluded = assert_exclusions_hold(&manifest, &plan); + + let mut replayed = 0_u32; + let mut refusals = 0_u32; + let mut findings = 0_usize; + let mut failures: Vec = Vec::new(); + for (name, facts_path) in &plan { + if excluded.contains_key(name) { + continue; + } + match replay_case(name, facts_path) { + Ok((true, _)) => refusals = refusals.checked_add(1).expect("count fits u32"), + Ok((false, n)) => findings = findings.checked_add(n).expect("count fits usize"), + Err(divergence) => failures.push(divergence), + } + replayed = replayed.checked_add(1).expect("count fits u32"); + } + assert!( + failures.is_empty(), + "{} verdict divergence(s):\n{}", + failures.len(), + failures.join("\n") + ); + eprintln!( + "cp4 verdict surface (identity/anchor/kind/tiering): {replayed} cases replayed \ + ({refusals} refusals, {findings} findings), \ + {} declared exclusions held", + excluded.len() + ); + assert!( + replayed >= 66, + "expected at least 66 replayed cases, got {replayed}" + ); + assert!( + refusals >= 5, + "the refusal classes (vocabulary skew, unknown kind, map-or-raise) must stay pinned, \ + got {refusals}" + ); +} diff --git a/rust/crates/own-diagnostics/tests/dag.rs b/rust/crates/own-diagnostics/tests/dag.rs index f3d788f4..71f309f2 100644 --- a/rust/crates/own-diagnostics/tests/dag.rs +++ b/rust/crates/own-diagnostics/tests/dag.rs @@ -40,15 +40,26 @@ fn allowed_edges() -> HashMap<&'static str, BTreeSet<&'static str>> { // never the reverse, and the surface must stay implementable without the // lowering that fills it. m.insert("own-lowered", BTreeSet::new()); - // The OwnIR -> Layer 2 lowering (#259 slice 3): a pure transformation - // crate. It READS the typed fact contract (own-ir) and CONSTRUCTS the - // typed Layer 2 surface (own-lowered) — both arrows point INTO data - // leaves, never the reverse, and own-lowered stays leaf (implementable - // without the lowering that fills it). No analysis/diagnostics edge: - // lowering must stay a pure OwnIr -> LoweredDocument function. + // The OwnIR bridge (#259): P-022's one deliberately WIDE consumer. It + // READS the typed fact contract (own-ir), CONSTRUCTS the typed Layer 2 + // surface (own-lowered) and the core AST (own-syntax), drives the + // own-cfg/own-analysis pipeline, and reads the verdict model + // (own-diagnostics) to map results back to fact handles (cp4). Every + // arrow points from the bridge INTO the core — the constraint runs the + // other way: no core crate may depend on own-bridge (none lists it), so + // bridge inference can never leak into the solver or the verdict layer. m.insert( "own-bridge", - ["own-ir", "own-lowered"].into_iter().collect(), + [ + "own-ir", + "own-lowered", + "own-syntax", + "own-cfg", + "own-analysis", + "own-diagnostics", + ] + .into_iter() + .collect(), ); // own-analysis CONSTRUCTS diagnostics and consumes the cfg lowering. It reads // the effect type through `own_cfg::Effect`, NOT the parser — so there is no @@ -174,6 +185,31 @@ fn own_analysis_has_no_production_parser_edge() { ); } +#[test] +fn no_core_crate_depends_on_the_bridge() { + // P-022 §fitness: own-bridge is the one deliberately wide consumer, and + // the constraint runs the other way — only ENTRY-POINT crates (own-cli, a + // future own-lsp/own-capi) may depend on it, never a core crate, so bridge + // inference can never leak into the solver or the verdict layer. Asserted + // by name so a regression says what it broke even if the map is edited. + let actual = workspace_edges(); + for core in [ + "own-ir", + "own-syntax", + "own-cfg", + "own-diagnostics", + "own-lowered", + "own-analysis", + ] { + let deps = actual.get(core).expect("core crate is a member"); + assert!( + !deps.contains("own-bridge"), + "{core} grew a dependency on own-bridge; the bridge consumes the core, \ + never the reverse" + ); + } +} + #[test] fn own_ir_is_a_leaf() { let actual = workspace_edges(); diff --git a/spec/Bridge.md b/spec/Bridge.md index 57c1fcec..67fc35db 100644 --- a/spec/Bridge.md +++ b/spec/Bridge.md @@ -365,13 +365,30 @@ committed regeneration path and a zero-Python steady state: the golden used only as expected output. With **OD-2/#294 resolved** (IR4-everywhere fail-loud), `tolerant_unknown_kind` is now one of those shared cases — its `Rejected` golden pins the identical error text on both - sides — so there are **no `rust_replay: false` snapshots left**. Layer 1, - Layer 3, analysis wiring, and #259 as a whole remain open. + sides — so there are **no `rust_replay: false` snapshots left**. Layer 1 + landed in `own-ir` (#259 cp1: 216 controls, 0/0/0); Layer 3 is built at the + checkpoint-4 surface (below); #259 as a whole remains open on cp5. - **Layer 3 — final normalized diagnostics.** The findings list (and its SARIF/github/msbuild renderings) per facts fixture, byte-exact — the outer - contract. Existing seeds: the end-to-end expectations in `test_ownir.py` - and the SARIF projections in `test_diag_sarif.py`; the `summaries` dump - (INF-R1) covers the MOS sub-surface. + contract. **Built** (#259 cp4): `ownlang/verdicts.py` is the authoritative + Python emitter (`VERDICTS_VERSION` keys the surface; its docstring freezes + the normalization decisions — every `Finding` member in declaration order, + the bridge's own ordering, `[file, line, label]` evidence triples, a + refusal as `{"error": …}`), `tests/fixtures/verdicts/` holds the goldens + under the frozen `manifest.json` ledger, and `tests/test_verdict_fixtures.py` + is the verify/`--write` harness. The cases are the swept `ownir`/`lowered`/ + `summaries` corpora plus synthetic verdict controls, all through the + **tolerant door** (`check_facts` on the loaded dict, as `test_ownir.py` + does). On the Rust side `own_bridge::check_facts` replays every case + (`own-bridge/tests/verdicts.rs`); at cp4 the replay compares **identity, + anchor, kind and tiering** — every member but `message`, `related` and + `flow`, which the goldens already carry and cp5 compares. The manifest's + `rust_replay_excluded` ledger names the documents the Rust core **refuses + by a declared boundary** — a protocol-bearing document (OBL analysis not + ported), a coordinate outside the core's `u32` line domain, a shape the + typed Rust door rejects before the bridge runs (OD-1) — each with its + reason and an expectation the replay executes, so an exclusion cannot rot. + The `summaries` dump (INF-R1) covers the MOS sub-surface. Regeneration: each layer gets a `--write` mode mirroring `tests/test_cfg_fixtures.py`; a stale committed fixture is a red build; the @@ -414,7 +431,12 @@ issue to be settled *before* the port relies on it. - **OD-1 (#294 — the tolerant door's scope).** Should Rust `own-bridge` expose the tolerant door (BR-D2) at all, or is strict-only + a Python-side test shim - the porting contract? + the porting contract? *Measured at #259 cp4:* the Rust tolerant entry is the + typed `OwnIr` constructor, so BR-D2's skip-not-coerce and the finders' + unknown-lifetime tolerance are unreachable through it — the verdict + ledger's two `verdict_door_*` controls pin exactly where (the reference + reports a finding, the constructor refuses the document); the bridge-side + port of those rules is pinned at the raw-document level meanwhile. - **OD-2 (#294 — unknown kind on the tolerant door). RESOLVED: IR4 everywhere.** `to_module`/`to_own` used to silently route a present-but-unknown `resource` kind as `subscription` when `load()` was bypassed. The lowerer now fails loud diff --git a/spec/BridgeBehaviorMatrix.md b/spec/BridgeBehaviorMatrix.md index bb54b46a..30903142 100644 --- a/spec/BridgeBehaviorMatrix.md +++ b/spec/BridgeBehaviorMatrix.md @@ -125,5 +125,18 @@ flow lowering), reproducing every `rust_replay: true` golden byte-for-byte. With OD-2/#294 resolved (IR4-everywhere fail-loud), `tolerant_unknown_kind` is now one of those shared cases — its `Rejected` golden pins the identical error text on both sides — so no Python-only cases remain. The per-case coverage of -the fixture family is listed in the #259 foundation PR; Layer 1 and Layer 3 -remain open. +the fixture family is listed in the #259 foundation PR. Layer 1 landed in +`own-ir` (#259 cp1). **Layer 3 is built** (#259 cp4): `ownlang/verdicts.py` + +`tests/fixtures/verdicts/` + `tests/test_verdict_fixtures.py`, replayed by +`rust/crates/own-bridge/tests/verdicts.rs` through `own_bridge::check_facts` +— so every **L3** row above has a directly-pinned end-to-end surface. At cp4 +the replay compares each finding's identity, anchor, kind and tiering; the +rows whose substance is message text or an evidence slice (the BR-V4 +wording matrix, the `flow`/`related` steps, the BR-V9 renderings) are +carried by the goldens and compared at cp5. Two row families are outside +the replayed set by declaration, recorded in the manifest's +`rust_replay_excluded` ledger with an executable expectation: the +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). diff --git a/tests/fixtures/verdicts/alias_join_cases.verdicts.json b/tests/fixtures/verdicts/alias_join_cases.verdicts.json new file mode 100644 index 00000000..3a75645f --- /dev/null +++ b/tests/fixtures/verdicts/alias_join_cases.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "F.cs", + "line": 7, + "code": "OWN001", + "component": "NoClaim", + "event": "q", + "handler": "", + "message": "IDisposable local 'q' may not be disposed on every path (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/capture.verdicts.json b/tests/fixtures/verdicts/capture.verdicts.json new file mode 100644 index 00000000..f851b1c3 --- /dev/null +++ b/tests/fixtures/verdicts/capture.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "ThemeViewModel.cs", + "line": 18, + "code": "OWN014", + "component": "ThemeViewModel", + "event": "SystemEvents.UserPreferenceChanged", + "handler": "OnUserPreferenceChanged", + "message": "event 'SystemEvents.UserPreferenceChanged' is subscribed (handler 'OnUserPreferenceChanged') to a static (process-lived) event source that outlives 'ThemeViewModel'; the strong subscription promotes 'ThemeViewModel' to the source's lifetime, so it can never be collected — a region escape (leak, no release path)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/contract_inference.verdicts.json b/tests/fixtures/verdicts/contract_inference.verdicts.json new file mode 100644 index 00000000..2471d084 --- /dev/null +++ b/tests/fixtures/verdicts/contract_inference.verdicts.json @@ -0,0 +1,48 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Infer.cs", + "line": 24, + "code": "OWN002", + "component": "run", + "event": "s", + "handler": "", + "message": "IDisposable local 's' is used after it is disposed", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Infer.cs", + 24, + "acquired 's' here" + ], + [ + "Infer.cs", + 26, + "used here after it was released/returned" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Infer.cs", + "line": 31, + "code": "OWN001", + "component": "keep", + "event": "s", + "handler": "", + "message": "IDisposable local 's' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/di.verdicts.json b/tests/fixtures/verdicts/di.verdicts.json new file mode 100644 index 00000000..44989631 --- /dev/null +++ b/tests/fixtures/verdicts/di.verdicts.json @@ -0,0 +1,76 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Startup.cs", + "line": 12, + "code": "DI001", + "component": "EmailSender", + "event": "AppDbContext", + "handler": "", + "message": "singleton 'EmailSender' captures scoped service 'AppDbContext' (captive dependency: EmailSender -> AppDbContext) [consumed by the 'EmailSender' constructor at EmailSender.cs:5]", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [ + [ + "EmailSender.cs", + 5, + "consuming constructor of 'EmailSender'" + ] + ], + "flow": [ + [ + "Startup.cs", + 12, + "singleton 'EmailSender' (captor)" + ], + [ + "Startup.cs", + 13, + "captures scoped service 'AppDbContext'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Startup.cs", + "line": 15, + "code": "DI001", + "component": "ReportService", + "event": "AppDbContext", + "handler": "", + "message": "singleton 'ReportService' captures scoped service 'AppDbContext' (captive dependency: ReportService -> UnitOfWork -> AppDbContext) [consumed by the 'ReportService' constructor at ReportService.cs:7]", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [ + [ + "ReportService.cs", + 7, + "consuming constructor of 'ReportService'" + ] + ], + "flow": [ + [ + "Startup.cs", + 15, + "singleton 'ReportService' (captor)" + ], + [ + "Startup.cs", + 16, + "via 'UnitOfWork'" + ], + [ + "Startup.cs", + 13, + "captures scoped service 'AppDbContext'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/di_capture.verdicts.json b/tests/fixtures/verdicts/di_capture.verdicts.json new file mode 100644 index 00000000..482ca483 --- /dev/null +++ b/tests/fixtures/verdicts/di_capture.verdicts.json @@ -0,0 +1,32 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "CustomerViewModel.cs", + "line": 14, + "code": "OWN014", + "component": "CustomerViewModel", + "event": "_bus.CustomerChanged", + "handler": "OnCustomerChanged", + "message": "event '_bus.CustomerChanged' is subscribed (handler 'OnCustomerChanged') to 'IEventBus' — a DI singleton (application-lifetime) service that outlives 'CustomerViewModel'; the strong subscription promotes 'CustomerViewModel' to the source's lifetime, so it can never be collected — a captive/region escape (leak, no release path)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "CustomerViewModel.cs", + 14, + "'CustomerViewModel' subscribes '_bus.CustomerChanged' to 'IEventBus' here" + ], + [ + "Startup.cs", + 10, + "source 'IEventBus' (singleton) registered here — outlives 'CustomerViewModel'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/disposable.verdicts.json b/tests/fixtures/verdicts/disposable.verdicts.json new file mode 100644 index 00000000..02ad2319 --- /dev/null +++ b/tests/fixtures/verdicts/disposable.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "DisposableFieldViewModel.cs", + "line": 10, + "code": "OWN001", + "component": "ReportViewModel", + "event": "_cts", + "handler": "?", + "message": "IDisposable field '_cts' (type 'CancellationTokenSource') is never disposed — its owner 'ReportViewModel' leaks it (leak)", + "kind": "disposable field", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/flow_column_anchors.verdicts.json b/tests/fixtures/verdicts/flow_column_anchors.verdicts.json new file mode 100644 index 00000000..2dfde6d7 --- /dev/null +++ b/tests/fixtures/verdicts/flow_column_anchors.verdicts.json @@ -0,0 +1,101 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Sample.cs", + "line": 30, + "code": "OWN001", + "component": "ColumnAnchors.Sample.Consume", + "event": "owned", + "handler": "", + "message": "IDisposable local 'owned' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 26 + }, + { + "file": "Sample.cs", + "line": 40, + "code": "OWN001", + "component": "ColumnAnchors.Sample.Leaks", + "event": "a", + "handler": "", + "message": "IDisposable local 'a' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 13 + }, + { + "file": "Sample.cs", + "line": 40, + "code": "OWN001", + "component": "ColumnAnchors.Sample.Leaks", + "event": "b", + "handler": "", + "message": "IDisposable local 'b' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 44 + }, + { + "file": "Sample.cs", + "line": 44, + "code": "OWN001", + "component": "ColumnAnchors.Sample.Leaks", + "event": "hoisted", + "handler": "", + "message": "IDisposable local 'hoisted' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 21 + }, + { + "file": "View.xaml.cs", + "line": 12, + "code": "OWN001", + "component": "ColumnAnchors.View", + "event": "model.PropertyChanged", + "handler": "new PropertyChangedEventHandler(OnChanged)", + "message": "event 'model.PropertyChanged' is subscribed (handler 'new PropertyChangedEventHandler(OnChanged)') but never unsubscribed — the source keeps 'ColumnAnchors.View' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 17 + }, + { + "file": "View.xaml.cs", + "line": 21, + "code": "OWN001", + "component": "ColumnAnchors.View", + "event": "_timer", + "handler": "?", + "message": "IDisposable field '_timer' (type 'System.Timers.Timer') is never disposed — its owner 'ColumnAnchors.View' leaks it (leak)", + "kind": "disposable field", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 9 + } + ] +} diff --git a/tests/fixtures/verdicts/flow_finally_switch.verdicts.json b/tests/fixtures/verdicts/flow_finally_switch.verdicts.json new file mode 100644 index 00000000..e6bf8766 --- /dev/null +++ b/tests/fixtures/verdicts/flow_finally_switch.verdicts.json @@ -0,0 +1,37 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "FlowLocalsSample.cs", + "line": 20, + "code": "OWN001", + "component": "FlowLocalsSample.FinallyReturnLeak", + "event": "r2", + "handler": "", + "message": "IDisposable local 'r2' may not be disposed on every path (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "FlowLocalsSample.cs", + "line": 40, + "code": "OWN001", + "component": "FlowLocalsSample.SwitchOneLeak", + "event": "s2", + "handler": "", + "message": "IDisposable local 's2' may not be disposed on every path (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/flow_kill_on_rebind.verdicts.json b/tests/fixtures/verdicts/flow_kill_on_rebind.verdicts.json new file mode 100644 index 00000000..ca6d528c --- /dev/null +++ b/tests/fixtures/verdicts/flow_kill_on_rebind.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "F.cs", + "line": 2, + "code": "OWN001", + "component": "M", + "event": "x", + "handler": "", + "message": "IDisposable local 'x' may not be disposed on every path (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/flow_leak_on_else.verdicts.json b/tests/fixtures/verdicts/flow_leak_on_else.verdicts.json new file mode 100644 index 00000000..7266c9ee --- /dev/null +++ b/tests/fixtures/verdicts/flow_leak_on_else.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "FlowLocalsSample.cs", + "line": 23, + "code": "OWN001", + "component": "FlowLocalsSample.LeakOnElse", + "event": "leak", + "handler": "", + "message": "IDisposable local 'leak' may not be disposed on every path (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/flow_leak_two_exits.verdicts.json b/tests/fixtures/verdicts/flow_leak_two_exits.verdicts.json new file mode 100644 index 00000000..eb72619f --- /dev/null +++ b/tests/fixtures/verdicts/flow_leak_two_exits.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "FlowLocalsSample.cs", + "line": 105, + "code": "OWN001", + "component": "FlowLocalsSample.TryNeverDisposed", + "event": "tfLeak", + "handler": "", + "message": "IDisposable local 'tfLeak' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/flow_nested_throw.verdicts.json b/tests/fixtures/verdicts/flow_nested_throw.verdicts.json new file mode 100644 index 00000000..72e997af --- /dev/null +++ b/tests/fixtures/verdicts/flow_nested_throw.verdicts.json @@ -0,0 +1,37 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "FlowLocalsSample.cs", + "line": 201, + "code": "OWN001", + "component": "FlowLocalsSample.NestedThrowLeaks", + "event": "nestedLeak", + "handler": "", + "message": "IDisposable local 'nestedLeak' may not be disposed on every path (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "FlowLocalsSample.cs", + "line": 217, + "code": "OWN001", + "component": "FlowLocalsSample.CtorThrowLeaksPrior", + "event": "ctorPrior", + "handler": "", + "message": "IDisposable local 'ctorPrior' may not be disposed on every path (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/flow_pool_partial.verdicts.json b/tests/fixtures/verdicts/flow_pool_partial.verdicts.json new file mode 100644 index 00000000..e46d2bf3 --- /dev/null +++ b/tests/fixtures/verdicts/flow_pool_partial.verdicts.json @@ -0,0 +1,53 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "PoolFlow.cs", + "line": 10, + "code": "OWN001", + "component": "PoolFlow.PartialPoolLeak", + "event": "buf", + "handler": "", + "message": "pooled buffer 'buf' may not be returned to the pool on every path (leak)", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "PoolFlow.cs", + "line": 20, + "code": "OWN001", + "component": "PoolFlow.NeverReturnedPool", + "event": "nbuf", + "handler": "", + "message": "pooled buffer 'nbuf' is rented but never returned to the pool (leak)", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "PoolFlow.cs", + "line": 30, + "code": "OWN001", + "component": "PoolFlow.DisposableControl", + "event": "d", + "handler": "", + "message": "IDisposable local 'd' may not be disposed on every path (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/flow_unmapped_refs.verdicts.json b/tests/fixtures/verdicts/flow_unmapped_refs.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/flow_unmapped_refs.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/flow_while.verdicts.json b/tests/fixtures/verdicts/flow_while.verdicts.json new file mode 100644 index 00000000..92724485 --- /dev/null +++ b/tests/fixtures/verdicts/flow_while.verdicts.json @@ -0,0 +1,48 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "FlowLocalsSample.cs", + "line": 20, + "code": "OWN001", + "component": "FlowLocalsSample.WhileXIter", + "event": "c", + "handler": "", + "message": "IDisposable local 'c' may not be disposed on every path (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "FlowLocalsSample.cs", + "line": 20, + "code": "OWN003", + "component": "FlowLocalsSample.WhileXIter", + "event": "c", + "handler": "", + "message": "IDisposable local 'c' is disposed more than once", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "FlowLocalsSample.cs", + 20, + "acquired 'c' here" + ], + [ + "FlowLocalsSample.cs", + 22, + "released/returned here a second time" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/fn_params_ordering.verdicts.json b/tests/fixtures/verdicts/fn_params_ordering.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/fn_params_ordering.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/handles_global_counters.verdicts.json b/tests/fixtures/verdicts/handles_global_counters.verdicts.json new file mode 100644 index 00000000..f89bb2a4 --- /dev/null +++ b/tests/fixtures/verdicts/handles_global_counters.verdicts.json @@ -0,0 +1,69 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "A.cs", + "line": 3, + "code": "OWN014", + "component": "A", + "event": "SystemEvents.A", + "handler": "HA", + "message": "event 'SystemEvents.A' is subscribed (handler 'HA') to a static (process-lived) event source that outlives 'A'; the strong subscription promotes 'A' to the source's lifetime, so it can never be collected — a region escape (leak, no release path)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "A.cs", + "line": 4, + "code": "OWN001", + "component": "A", + "event": "bus.A", + "handler": "HB", + "message": "event 'bus.A' is subscribed (handler 'HB') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'A' alive (possible leak)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "B.cs", + "line": 3, + "code": "OWN001", + "component": "B", + "event": "bus.B", + "handler": "HC", + "message": "event 'bus.B' is subscribed (handler 'HC') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'B' alive (possible leak)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "B.cs", + "line": 4, + "code": "OWN014", + "component": "B", + "event": "SystemEvents.B", + "handler": "HD", + "message": "event 'SystemEvents.B' is subscribed (handler 'HD') to a static (process-lived) event source that outlives 'B'; the strong subscription promotes 'B' to the source's lifetime, so it can never be collected — a region escape (leak, no release path)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/handles_null_metadata.verdicts.json b/tests/fixtures/verdicts/handles_null_metadata.verdicts.json new file mode 100644 index 00000000..9bb2dd07 --- /dev/null +++ b/tests/fixtures/verdicts/handles_null_metadata.verdicts.json @@ -0,0 +1,37 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "C.cs", + "line": 4, + "code": "OWN001", + "component": "C", + "event": "bus.Changed", + "handler": "OnChanged", + "message": "event 'bus.Changed' is subscribed (handler 'OnChanged') but never unsubscribed — the source keeps 'C' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "C.cs", + "line": 9, + "code": "OWN001", + "component": "C", + "event": "bus.Closed", + "handler": "OnClosed", + "message": "event 'bus.Closed' is subscribed (handler 'OnClosed') but never unsubscribed — the source keeps 'C' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/handoff_contract.verdicts.json b/tests/fixtures/verdicts/handoff_contract.verdicts.json new file mode 100644 index 00000000..b6e6669b --- /dev/null +++ b/tests/fixtures/verdicts/handoff_contract.verdicts.json @@ -0,0 +1,48 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Archiver.cs", + "line": 18, + "code": "OWN001", + "component": "leak", + "event": "s", + "handler": "", + "message": "IDisposable local 's' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Archiver.cs", + "line": 24, + "code": "OWN002", + "component": "run", + "event": "s", + "handler": "", + "message": "IDisposable local 's' is used after it is disposed", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Archiver.cs", + 24, + "acquired 's' here" + ], + [ + "Archiver.cs", + 26, + "used here after it was released/returned" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/hoist_neg_early_return.verdicts.json b/tests/fixtures/verdicts/hoist_neg_early_return.verdicts.json new file mode 100644 index 00000000..7b8c30d6 --- /dev/null +++ b/tests/fixtures/verdicts/hoist_neg_early_return.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message=\"undefined name 'loc_0'\"). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding." +} diff --git a/tests/fixtures/verdicts/hoist_neg_nested_depth.verdicts.json b/tests/fixtures/verdicts/hoist_neg_nested_depth.verdicts.json new file mode 100644 index 00000000..7b8c30d6 --- /dev/null +++ b/tests/fixtures/verdicts/hoist_neg_nested_depth.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message=\"undefined name 'loc_0'\"). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding." +} diff --git a/tests/fixtures/verdicts/hoist_neg_while_body.verdicts.json b/tests/fixtures/verdicts/hoist_neg_while_body.verdicts.json new file mode 100644 index 00000000..7b8c30d6 --- /dev/null +++ b/tests/fixtures/verdicts/hoist_neg_while_body.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "error": "internal: the core reported [OWN030] on the lowered facts that the bridge cannot map back to a C# subscription (subject=None, message=\"undefined name 'loc_0'\"). The OwnIR lowering has drifted from the core; teach the bridge this diagnostic rather than dropping the finding." +} diff --git a/tests/fixtures/verdicts/hoist_pool_kind.verdicts.json b/tests/fixtures/verdicts/hoist_pool_kind.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/hoist_pool_kind.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/hoist_positive_release.verdicts.json b/tests/fixtures/verdicts/hoist_positive_release.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/hoist_positive_release.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/hoist_positive_use_only.verdicts.json b/tests/fixtures/verdicts/hoist_positive_use_only.verdicts.json new file mode 100644 index 00000000..32e4b250 --- /dev/null +++ b/tests/fixtures/verdicts/hoist_positive_use_only.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "F.cs", + "line": 3, + "code": "OWN001", + "component": "M", + "event": "r", + "handler": "", + "message": "IDisposable local 'r' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/lines_preserved.verdicts.json b/tests/fixtures/verdicts/lines_preserved.verdicts.json new file mode 100644 index 00000000..9751c692 --- /dev/null +++ b/tests/fixtures/verdicts/lines_preserved.verdicts.json @@ -0,0 +1,48 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "C.cs", + "line": 41, + "code": "OWN001", + "component": "C", + "event": "bus.E", + "handler": "H", + "message": "event 'bus.E' is subscribed (handler 'H') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'C' alive (possible leak)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "F.cs", + "line": 102, + "code": "OWN025", + "component": "M", + "event": "x", + "handler": "", + "message": "pooled buffer 'x' is viewed at its full length, past the logical length it was rented for (over-read / over-clear)", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "F.cs", + 101, + "rented 'x' here" + ], + [ + "F.cs", + 102, + "viewed here at full length, past what it was rented for" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/local_disposable.verdicts.json b/tests/fixtures/verdicts/local_disposable.verdicts.json new file mode 100644 index 00000000..97fe635e --- /dev/null +++ b/tests/fixtures/verdicts/local_disposable.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "LocalDisposableSample.cs", + "line": 12, + "code": "OWN001", + "component": "Loader", + "event": "leaky", + "handler": "?", + "message": "local IDisposable 'leaky' (type 'MemoryStream') is created but never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/manifest.json b/tests/fixtures/verdicts/manifest.json new file mode 100644 index 00000000..4cd4aa00 --- /dev/null +++ b/tests/fixtures/verdicts/manifest.json @@ -0,0 +1,195 @@ +{ + "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.", + "rust_refusal": "bridge", + "rust_error_contains": "outside the core's line domain" + }, + { + "name": "verdict_boundary_line_above_u32", + "reason": "coordinate outside the core's u32 line domain (an acquire at 2^32); the reference anchors OWN001 there, the Rust core refuses — declared cp4 divergence family.", + "rust_refusal": "bridge", + "rust_error_contains": "outside the core's line domain" + }, + { + "name": "verdict_boundary_service_line_negative", + "reason": "coordinate outside the core's u32 line domain on the DI path (registration line -5); the reference anchors DI001 there, the Rust core refuses — declared cp4 divergence family.", + "rust_refusal": "bridge", + "rust_error_contains": "outside the core's line domain" + }, + { + "name": "verdict_boundary_effect_line_negative", + "reason": "coordinate outside the core's u32 line domain on the effect path (effect line -3); the reference anchors EFF001 there, the Rust core refuses — declared cp4 divergence family.", + "rust_refusal": "bridge", + "rust_error_contains": "outside the core's line domain" + }, + { + "name": "verdict_door_effect_deps_not_strings", + "reason": "#294 OD-1: the reference's tolerant door skips a malformed effect entry (deps is a bare string) and reports the sibling; the Rust tolerant entry is the typed OwnIr constructor, which refuses the document before the bridge's skip rule can run.", + "rust_refusal": "door" + }, + { + "name": "verdict_door_service_unknown_lifetime", + "reason": "#294 OD-1: the reference's finders ignore an unknown DI lifetime on the tolerant door; the typed Rust door refuses the document at the lifetime enum.", + "rust_refusal": "door" + } + ], + "cases": [ + { + "name": "verdict_eff001_storm_and_memo", + "rules": [ + "BR-P2", + "BR-V5", + "BR-V8" + ] + }, + { + "name": "verdict_di_graph_families", + "rules": [ + "BR-P1", + "BR-V1", + "BR-V5", + "BR-V6" + ] + }, + { + "name": "verdict_di004_call_site_anchor", + "rules": [ + "BR-P1", + "BR-V5" + ] + }, + { + "name": "verdict_di005_cache_site_anchor", + "rules": [ + "BR-P1", + "BR-V5" + ] + }, + { + "name": "verdict_di_duplicate_sites_last_wins", + "rules": [ + "BR-P1", + "BR-V5" + ] + }, + { + "name": "verdict_di_tolerant_typed_coercions", + "rules": [ + "BR-D2", + "BR-P1", + "BR-V5" + ] + }, + { + "name": "verdict_multi_file_ordering", + "rules": [ + "BR-V1", + "BR-V8" + ] + }, + { + "name": "verdict_severity_tiers_and_suppression", + "rules": [ + "BR-V4", + "BR-V5", + "BR-V6" + ] + }, + { + "name": "verdict_own051_gates", + "rules": [ + "BR-L8", + "INF-A5", + "BR-V1", + "BR-V6" + ] + }, + { + "name": "verdict_pool_view_anchor", + "rules": [ + "BR-V4", + "BR-V5", + "BR-V7" + ] + }, + { + "name": "verdict_column_sort_order", + "rules": [ + "BR-V5", + "BR-V8" + ] + }, + { + "name": "verdict_same_site_distinct_events", + "rules": [ + "BR-V7", + "BR-V8" + ] + }, + { + "name": "verdict_skip_list_artifacts", + "rules": [ + "BR-V2", + "BR-V3" + ] + }, + { + "name": "verdict_boundary_line_negative", + "rules": [ + "BR-L11", + "BR-V5" + ] + }, + { + "name": "verdict_boundary_line_above_u32", + "rules": [ + "BR-L11", + "BR-V5" + ] + }, + { + "name": "verdict_boundary_service_line_negative", + "rules": [ + "BR-P1", + "BR-V5" + ] + }, + { + "name": "verdict_boundary_effect_line_negative", + "rules": [ + "BR-P2", + "BR-V5" + ] + }, + { + "name": "verdict_door_effect_deps_not_strings", + "rules": [ + "BR-D2", + "BR-P2" + ] + }, + { + "name": "verdict_door_service_unknown_lifetime", + "rules": [ + "BR-D2", + "BR-P1" + ] + } + ] +} diff --git a/tests/fixtures/verdicts/mos_call_channel_overload_sig.verdicts.json b/tests/fixtures/verdicts/mos_call_channel_overload_sig.verdicts.json new file mode 100644 index 00000000..da9dfe74 --- /dev/null +++ b/tests/fixtures/verdicts/mos_call_channel_overload_sig.verdicts.json @@ -0,0 +1,64 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "F.cs", + "line": 1, + "code": "OWN001", + "component": "Take", + "event": "p", + "handler": "", + "message": "IDisposable local 'p' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "F.cs", + "line": 12, + "code": "OWN002", + "component": "M", + "event": "c1", + "handler": "", + "message": "IDisposable local 'c1' is used after it is disposed", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "F.cs", + 12, + "acquired 'c1' here" + ], + [ + "F.cs", + 14, + "used here after it was released/returned" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "F.cs", + "line": 16, + "code": "OWN051", + "component": "M", + "event": "c2", + "handler": "Take", + "message": "cannot verify whether 'Take' takes ownership of 'c2' (inferred contract: may); optimistically assuming it does — 'c2' is not checked past this call", + "kind": "ownership transfer", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/mos_call_direct_consume.verdicts.json b/tests/fixtures/verdicts/mos_call_direct_consume.verdicts.json new file mode 100644 index 00000000..9fc2d1b1 --- /dev/null +++ b/tests/fixtures/verdicts/mos_call_direct_consume.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "F.cs", + "line": 1, + "code": "OWN001", + "component": "Sink", + "event": "p", + "handler": "", + "message": "IDisposable local 'p' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/mos_call_unknown_drop.verdicts.json b/tests/fixtures/verdicts/mos_call_unknown_drop.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/mos_call_unknown_drop.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/mos_fresh_mint.verdicts.json b/tests/fixtures/verdicts/mos_fresh_mint.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/mos_fresh_mint.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/mos_killsite_toplevel.verdicts.json b/tests/fixtures/verdicts/mos_killsite_toplevel.verdicts.json new file mode 100644 index 00000000..c105a903 --- /dev/null +++ b/tests/fixtures/verdicts/mos_killsite_toplevel.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "F.cs", + "line": 14, + "code": "OWN051", + "component": "M", + "event": "y", + "handler": "MayTake", + "message": "cannot verify whether 'MayTake' takes ownership of 'y' (inferred contract: may); optimistically assuming it does — 'y' is not checked past this call", + "kind": "ownership transfer", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/mos_untrack_inbranch.verdicts.json b/tests/fixtures/verdicts/mos_untrack_inbranch.verdicts.json new file mode 100644 index 00000000..24bee88c --- /dev/null +++ b/tests/fixtures/verdicts/mos_untrack_inbranch.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "F.cs", + "line": 14, + "code": "OWN051", + "component": "M", + "event": "x", + "handler": "MayTake", + "message": "cannot verify whether 'MayTake' takes ownership of 'x' (inferred contract: may); optimistically assuming it does — 'x' is not checked past this call", + "kind": "ownership transfer", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/mosdump_degraded_duplicate_key.verdicts.json b/tests/fixtures/verdicts/mosdump_degraded_duplicate_key.verdicts.json new file mode 100644 index 00000000..2dd6ba71 --- /dev/null +++ b/tests/fixtures/verdicts/mosdump_degraded_duplicate_key.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "?", + "line": 0, + "code": "OWN052", + "component": "Degraded", + "event": "", + "handler": "", + "message": "interprocedural summary inference failed (ValueError: duplicate MethodSkeleton key: Take(System.IO.Stream)); method summaries skipped — cross-method ownership transfer was not checked this run", + "kind": "method summaries", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/mosdump_explicit_effects.verdicts.json b/tests/fixtures/verdicts/mosdump_explicit_effects.verdicts.json new file mode 100644 index 00000000..1e99423b --- /dev/null +++ b/tests/fixtures/verdicts/mosdump_explicit_effects.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "ef.cs", + "line": 1, + "code": "OWN001", + "component": "Ef.C", + "event": "a", + "handler": "", + "message": "IDisposable local 'a' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/mosdump_nonascii_escaping.verdicts.json b/tests/fixtures/verdicts/mosdump_nonascii_escaping.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/mosdump_nonascii_escaping.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/mosdump_overload_merge_tiebreaks.verdicts.json b/tests/fixtures/verdicts/mosdump_overload_merge_tiebreaks.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/mosdump_overload_merge_tiebreaks.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/mosdump_partial_and_guarded.verdicts.json b/tests/fixtures/verdicts/mosdump_partial_and_guarded.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/mosdump_partial_and_guarded.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/mosdump_return_chains.verdicts.json b/tests/fixtures/verdicts/mosdump_return_chains.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/mosdump_return_chains.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/mosdump_scc_cycles.verdicts.json b/tests/fixtures/verdicts/mosdump_scc_cycles.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/mosdump_scc_cycles.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/mosdump_sig_vocabulary.verdicts.json b/tests/fixtures/verdicts/mosdump_sig_vocabulary.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/mosdump_sig_vocabulary.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/mosdump_sink_channels.verdicts.json b/tests/fixtures/verdicts/mosdump_sink_channels.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/mosdump_sink_channels.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/pool.verdicts.json b/tests/fixtures/verdicts/pool.verdicts.json new file mode 100644 index 00000000..effa5741 --- /dev/null +++ b/tests/fixtures/verdicts/pool.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "PooledBufferSample.cs", + "line": 9, + "code": "OWN001", + "component": "Hasher", + "event": "leaky", + "handler": "?", + "message": "pooled buffer 'leaky' is rented but never returned to the pool (leak)", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/protocol_isloaded_clean.verdicts.json b/tests/fixtures/verdicts/protocol_isloaded_clean.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/protocol_isloaded_clean.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/protocol_isloaded_violation.verdicts.json b/tests/fixtures/verdicts/protocol_isloaded_violation.verdicts.json new file mode 100644 index 00000000..902a9992 --- /dev/null +++ b/tests/fixtures/verdicts/protocol_isloaded_violation.verdicts.json @@ -0,0 +1,37 @@ +{ + "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/verdicts/routing_r1_unresolved.verdicts.json b/tests/fixtures/verdicts/routing_r1_unresolved.verdicts.json new file mode 100644 index 00000000..3c0f3bf7 --- /dev/null +++ b/tests/fixtures/verdicts/routing_r1_unresolved.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "C.cs", + "line": 3, + "code": "OWN050", + "component": "C", + "event": "ext.E", + "handler": "H", + "message": "cannot verify 'ext.E' — 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/routing_r2_subscribe_self.verdicts.json b/tests/fixtures/verdicts/routing_r2_subscribe_self.verdicts.json new file mode 100644 index 00000000..9454b37e --- /dev/null +++ b/tests/fixtures/verdicts/routing_r2_subscribe_self.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "findings": [] +} diff --git a/tests/fixtures/verdicts/routing_r3_capture_static.verdicts.json b/tests/fixtures/verdicts/routing_r3_capture_static.verdicts.json new file mode 100644 index 00000000..de714fa0 --- /dev/null +++ b/tests/fixtures/verdicts/routing_r3_capture_static.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Win.cs", + "line": 5, + "code": "OWN014", + "component": "Win", + "event": "SystemEvents.DisplaySettingsChanged", + "handler": "OnD", + "message": "event 'SystemEvents.DisplaySettingsChanged' is subscribed (handler 'OnD') to a static (process-lived) event source that outlives 'Win'; the strong subscription promotes 'Win' to the source's lifetime, so it can never be collected — a region escape (leak, no release path)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/routing_r4_returned_fresh.verdicts.json b/tests/fixtures/verdicts/routing_r4_returned_fresh.verdicts.json new file mode 100644 index 00000000..e07ca0bc --- /dev/null +++ b/tests/fixtures/verdicts/routing_r4_returned_fresh.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "C.cs", + "line": 9, + "code": "OWN001", + "component": "C", + "event": "pub2.Changed", + "handler": "OnC2", + "message": "event 'pub2.Changed' is subscribed (handler 'OnC2') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'C' alive (possible leak)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/routing_r5_di_capture.verdicts.json b/tests/fixtures/verdicts/routing_r5_di_capture.verdicts.json new file mode 100644 index 00000000..344c7a65 --- /dev/null +++ b/tests/fixtures/verdicts/routing_r5_di_capture.verdicts.json @@ -0,0 +1,48 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "VM.cs", + "line": 10, + "code": "OWN014", + "component": "VM", + "event": "bus.Changed", + "handler": "OnC", + "message": "event 'bus.Changed' is subscribed (handler 'OnC') to 'EventBus' — a DI singleton (application-lifetime) service that outlives 'VM'; the strong subscription promotes 'VM' to the source's lifetime, so it can never be collected — a captive/region escape (leak, no release path)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "VM.cs", + 10, + "'VM' subscribes 'bus.Changed' to 'EventBus' here" + ], + [ + "Startup.cs", + 2, + "source 'EventBus' (singleton) registered here — outlives 'VM'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "VM.cs", + "line": 11, + "code": "OWN001", + "component": "VM", + "event": "cache.Evicted", + "handler": "OnE", + "message": "event 'cache.Evicted' is subscribed (handler 'OnE') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'VM' alive (possible leak)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/routing_r6_token_kinds.verdicts.json b/tests/fixtures/verdicts/routing_r6_token_kinds.verdicts.json new file mode 100644 index 00000000..fe14d98b --- /dev/null +++ b/tests/fixtures/verdicts/routing_r6_token_kinds.verdicts.json @@ -0,0 +1,101 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "C.cs", + "line": 3, + "code": "OWN001", + "component": "C", + "event": "bus.Changed", + "handler": "OnC", + "message": "event 'bus.Changed' is subscribed (handler 'OnC') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'C' alive (possible leak)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "C.cs", + "line": 5, + "code": "OWN001", + "component": "C", + "event": "_timer.Tick", + "handler": "OnT", + "message": "timer '_timer.Tick' (handler 'OnT') is started but never stopped or detached — the running timer keeps 'C' alive (leak)", + "kind": "timer", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "C.cs", + "line": 6, + "code": "OWN001", + "component": "C", + "event": "_conn", + "handler": "", + "message": "IDisposable field '_conn' (type 'SqlConnection') is never disposed — its owner 'C' leaks it (leak)", + "kind": "disposable field", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "C.cs", + "line": 7, + "code": "OWN001", + "component": "C", + "event": "reader", + "handler": "", + "message": "local IDisposable 'reader' is created but never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "C.cs", + "line": 8, + "code": "OWN001", + "component": "C", + "event": "buf", + "handler": "", + "message": "pooled buffer 'buf' is rented but never returned to the pool (leak)", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "C.cs", + "line": 9, + "code": "OWN001", + "component": "C", + "event": "obs.Subscribe(H)", + "handler": "H", + "message": "the result of 'obs.Subscribe(H)' is ignored — its IDisposable subscription is never disposed; the source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'C' alive (possible leak)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/sample.verdicts.json b/tests/fixtures/verdicts/sample.verdicts.json new file mode 100644 index 00000000..5f154bce --- /dev/null +++ b/tests/fixtures/verdicts/sample.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "CustomerViewModel.cs", + "line": 12, + "code": "OWN001", + "component": "CustomerViewModel", + "event": "bus.CustomerChanged", + "handler": "OnCustomerChanged", + "message": "event 'bus.CustomerChanged' is subscribed (handler 'OnCustomerChanged') but never unsubscribed — the source keeps 'CustomerViewModel' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/subscribe.verdicts.json b/tests/fixtures/verdicts/subscribe.verdicts.json new file mode 100644 index 00000000..7a8f7bef --- /dev/null +++ b/tests/fixtures/verdicts/subscribe.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "MessengerViewModel.cs", + "line": 12, + "code": "OWN001", + "component": "InboxViewModel", + "event": "_messenger.Subscribe", + "handler": "?", + "message": "the result of '_messenger.Subscribe' is ignored — the IDisposable subscription is never disposed, leaking 'InboxViewModel' (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/timer.verdicts.json b/tests/fixtures/verdicts/timer.verdicts.json new file mode 100644 index 00000000..f5267e76 --- /dev/null +++ b/tests/fixtures/verdicts/timer.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "TimerViewModel.cs", + "line": 15, + "code": "OWN001", + "component": "TimerViewModel", + "event": "_timer.Tick", + "handler": "OnTick", + "message": "timer '_timer.Tick' (handler 'OnTick') is started but never stopped or detached — the running timer keeps 'TimerViewModel' alive (leak)", + "kind": "timer", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/tolerant_unknown_kind.verdicts.json b/tests/fixtures/verdicts/tolerant_unknown_kind.verdicts.json new file mode 100644 index 00000000..5e267973 --- /dev/null +++ b/tests/fixtures/verdicts/tolerant_unknown_kind.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "error": "unknown resource kind 'mutex' — a new kind is a vocabulary change that must bump OWNIR_VERSION (see spec/OwnIR.md §2)" +} diff --git a/tests/fixtures/verdicts/unitofwork_flow.verdicts.json b/tests/fixtures/verdicts/unitofwork_flow.verdicts.json new file mode 100644 index 00000000..0ef60456 --- /dev/null +++ b/tests/fixtures/verdicts/unitofwork_flow.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "UnitOfWorkFlowSample.cs", + "line": 26, + "code": "OWN001", + "component": "CatalogService.GetProductsFromCatalogWODocuments", + "event": "uow", + "handler": "", + "message": "IDisposable local 'uow' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/unresolved.verdicts.json b/tests/fixtures/verdicts/unresolved.verdicts.json new file mode 100644 index 00000000..566ad942 --- /dev/null +++ b/tests/fixtures/verdicts/unresolved.verdicts.json @@ -0,0 +1,37 @@ +{ + "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 + }, + { + "file": "GridViewModel.cs", + "line": 22, + "code": "OWN001", + "component": "GridViewModel", + "event": "vm.PropertyChanged", + "handler": "OnProp", + "message": "event 'vm.PropertyChanged' is subscribed (handler 'OnProp') but never unsubscribed — the source keeps 'GridViewModel' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_boundary_effect_line_negative.facts.json b/tests/fixtures/verdicts/verdict_boundary_effect_line_negative.facts.json new file mode 100644 index 00000000..0dd331c9 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_boundary_effect_line_negative.facts.json @@ -0,0 +1,29 @@ +{ + "ownir_version": 0, + "module": "Bound", + "components": [], + "_doc": [ + "Declared #259 cp4 divergence family on the effect path: a negative effect", + "line is an EFF001 anchored at E.tsx:-3 for the reference; the Rust core", + "refuses." + ], + "effects": [ + { + "component": "E", + "file": "E.tsx", + "line": -3, + "io": true, + "deps": [ + "o" + ], + "bindings": [ + { + "name": "o", + "init": "object", + "refs": [], + "line": 1 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_boundary_effect_line_negative.verdicts.json b/tests/fixtures/verdicts/verdict_boundary_effect_line_negative.verdicts.json new file mode 100644 index 00000000..bf30b33d --- /dev/null +++ b/tests/fixtures/verdicts/verdict_boundary_effect_line_negative.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "E.tsx", + "line": -3, + "code": "EFF001", + "component": "E", + "event": "o", + "handler": "", + "message": "effect re-runs on every render: dependency 'o' 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 'o' with useMemo/useCallback (or move it out of render)", + "kind": "react effect", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_boundary_line_above_u32.facts.json b/tests/fixtures/verdicts/verdict_boundary_line_above_u32.facts.json new file mode 100644 index 00000000..4f958902 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_boundary_line_above_u32.facts.json @@ -0,0 +1,21 @@ +{ + "ownir_version": 0, + "module": "Bound", + "_doc": [ + "Declared #259 cp4 divergence family, upper edge: an acquire at line 2^32 is", + "a leak at that coordinate for the reference; the Rust core refuses." + ], + "functions": [ + { + "name": "Bound.G", + "file": "B.cs", + "body": [ + { + "op": "acquire", + "var": "x", + "line": 4294967296 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_boundary_line_above_u32.verdicts.json b/tests/fixtures/verdicts/verdict_boundary_line_above_u32.verdicts.json new file mode 100644 index 00000000..295a8ec8 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_boundary_line_above_u32.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "B.cs", + "line": 4294967296, + "code": "OWN001", + "component": "Bound.G", + "event": "x", + "handler": "", + "message": "IDisposable local 'x' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_boundary_line_negative.facts.json b/tests/fixtures/verdicts/verdict_boundary_line_negative.facts.json new file mode 100644 index 00000000..ad30b9fa --- /dev/null +++ b/tests/fixtures/verdicts/verdict_boundary_line_negative.facts.json @@ -0,0 +1,24 @@ +{ + "ownir_version": 0, + "module": "Bound", + "_doc": [ + "Declared #259 cp4 divergence family: a coordinate outside the core's u32", + "line domain. The strict door admits every signed 64-bit line, so the", + "reference leaks the consume parameter at B.cs:-1; the Rust core refuses the", + "document rather than clamp the coordinate." + ], + "functions": [ + { + "name": "Bound.F", + "file": "B.cs", + "params": [ + { + "name": "p", + "line": -1, + "effect": "consume" + } + ], + "body": [] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_boundary_line_negative.verdicts.json b/tests/fixtures/verdicts/verdict_boundary_line_negative.verdicts.json new file mode 100644 index 00000000..cd70477d --- /dev/null +++ b/tests/fixtures/verdicts/verdict_boundary_line_negative.verdicts.json @@ -0,0 +1,21 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "B.cs", + "line": -1, + "code": "OWN001", + "component": "Bound.F", + "event": "p", + "handler": "", + "message": "IDisposable local 'p' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_boundary_service_line_negative.facts.json b/tests/fixtures/verdicts/verdict_boundary_service_line_negative.facts.json new file mode 100644 index 00000000..a121e7f6 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_boundary_service_line_negative.facts.json @@ -0,0 +1,28 @@ +{ + "ownir_version": 0, + "module": "Bound", + "components": [], + "_doc": [ + "Declared #259 cp4 divergence family on the DI path: a negative registration", + "line is a DI001 anchored at reg.cs:-5 for the reference; the Rust core", + "refuses (the anchor cannot be represented)." + ], + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": -5, + "deps": [ + "Db" + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 6, + "deps": [] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_boundary_service_line_negative.verdicts.json b/tests/fixtures/verdicts/verdict_boundary_service_line_negative.verdicts.json new file mode 100644 index 00000000..aa2759c0 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_boundary_service_line_negative.verdicts.json @@ -0,0 +1,27 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "reg.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": [ + [ + "reg.cs", + 6, + "captures scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_column_sort_order.facts.json b/tests/fixtures/verdicts/verdict_column_sort_order.facts.json new file mode 100644 index 00000000..53caa314 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_column_sort_order.facts.json @@ -0,0 +1,56 @@ +{ + "ownir_version": 0, + "module": "Cols", + "_doc": [ + "BR-V8's column member: two leaks on the SAME line are minted in the order b", + "(column 44) then a (column 13), so insertion order alone would list b first;", + "the sort key's `column or 0` puts a first. The third acquire carries column 0,", + "which is not a 1-based coordinate: `_as_col` degrades it to null (absent),", + "never to 0, and an absent column sorts before every real one.", + "`x` is acquired twice on line 50 (the second acquire kills the first binding, BR-L6): two leaks whose identity differs ONLY in column — the member that keeps them apart in the dedup key." + ], + "functions": [ + { + "name": "Cols.F", + "file": "K.cs", + "body": [ + { + "op": "acquire", + "var": "b", + "line": 40, + "column": 44 + }, + { + "op": "acquire", + "var": "a", + "line": 40, + "column": 13 + }, + { + "op": "acquire", + "var": "c", + "line": 41, + "column": 0 + }, + { + "op": "acquire", + "var": "d", + "line": 41, + "column": 3 + }, + { + "op": "acquire", + "var": "x", + "line": 50, + "column": 5 + }, + { + "op": "acquire", + "var": "x", + "line": 50, + "column": 30 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_column_sort_order.verdicts.json b/tests/fixtures/verdicts/verdict_column_sort_order.verdicts.json new file mode 100644 index 00000000..87fbeb87 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_column_sort_order.verdicts.json @@ -0,0 +1,101 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "K.cs", + "line": 40, + "code": "OWN001", + "component": "Cols.F", + "event": "a", + "handler": "", + "message": "IDisposable local 'a' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 13 + }, + { + "file": "K.cs", + "line": 40, + "code": "OWN001", + "component": "Cols.F", + "event": "b", + "handler": "", + "message": "IDisposable local 'b' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 44 + }, + { + "file": "K.cs", + "line": 41, + "code": "OWN001", + "component": "Cols.F", + "event": "c", + "handler": "", + "message": "IDisposable local 'c' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "K.cs", + "line": 41, + "code": "OWN001", + "component": "Cols.F", + "event": "d", + "handler": "", + "message": "IDisposable local 'd' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 3 + }, + { + "file": "K.cs", + "line": 50, + "code": "OWN001", + "component": "Cols.F", + "event": "x", + "handler": "", + "message": "IDisposable local 'x' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 5 + }, + { + "file": "K.cs", + "line": 50, + "code": "OWN001", + "component": "Cols.F", + "event": "x", + "handler": "", + "message": "IDisposable local 'x' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 30 + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di004_call_site_anchor.facts.json b/tests/fixtures/verdicts/verdict_di004_call_site_anchor.facts.json new file mode 100644 index 00000000..903a5bd9 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di004_call_site_anchor.facts.json @@ -0,0 +1,109 @@ +{ + "ownir_version": 0, + "module": "Di004", + "components": [], + "_doc": [ + "DI004 anchors at the root-resolution CALL SITE of the entry type: directly", + "resolved Conn at Resolver.cs:42; the disposable Pool dragged in through the", + "non-disposable transient Mid still anchors at Mid's call site (Wrap.cs:88);", + "NoSite carries no site metadata and falls back to its registration (reg.cs:10);", + "Plain resolves a transient whose only dependency is scoped -> silent." + ], + "services": [ + { + "name": "Resolver", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [], + "root_resolves": [ + "Conn" + ], + "root_resolve_sites": [ + { + "type": "Conn", + "file": "Resolver.cs", + "line": 42 + } + ] + }, + { + "name": "Conn", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "deps": [], + "disposable": true + }, + { + "name": "Wrap", + "lifetime": "singleton", + "file": "reg.cs", + "line": 7, + "deps": [], + "root_resolves": [ + "Mid" + ], + "root_resolve_sites": [ + { + "type": "Mid", + "file": "Wrap.cs", + "line": 88 + } + ] + }, + { + "name": "Mid", + "lifetime": "transient", + "file": "reg.cs", + "line": 8, + "deps": [ + "Pool" + ] + }, + { + "name": "Pool", + "lifetime": "transient", + "file": "reg.cs", + "line": 9, + "deps": [], + "disposable": true + }, + { + "name": "NoSite", + "lifetime": "singleton", + "file": "reg.cs", + "line": 10, + "deps": [], + "root_resolves": [ + "Conn" + ] + }, + { + "name": "Plain", + "lifetime": "singleton", + "file": "reg.cs", + "line": 11, + "deps": [], + "root_resolves": [ + "Uow" + ] + }, + { + "name": "Uow", + "lifetime": "transient", + "file": "reg.cs", + "line": 12, + "deps": [ + "Db" + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 13, + "deps": [] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di004_call_site_anchor.verdicts.json b/tests/fixtures/verdicts/verdict_di004_call_site_anchor.verdicts.json new file mode 100644 index 00000000..b4144242 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di004_call_site_anchor.verdicts.json @@ -0,0 +1,103 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Resolver.cs", + "line": 42, + "code": "DI004", + "component": "Resolver", + "event": "Conn", + "handler": "", + "message": "singleton 'Resolver' resolves transient IDisposable 'Conn' by hand from its injected root IServiceProvider (GetService/GetRequiredService — the service-locator anti-pattern): the root provider tracks every IDisposable it resolves and frees them only at application shutdown, so each call leaks a transient that should be scope-lived — resolve it from an IServiceScope instead (Resolver -> Conn) [singleton registered at reg.cs:5]", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [ + [ + "reg.cs", + 5, + "registration of singleton 'Resolver'" + ] + ], + "flow": [ + [ + "reg.cs", + 5, + "singleton 'Resolver' (captor)" + ], + [ + "reg.cs", + 6, + "leaks transient IDisposable 'Conn'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Wrap.cs", + "line": 88, + "code": "DI004", + "component": "Wrap", + "event": "Pool", + "handler": "", + "message": "singleton 'Wrap' resolves transient IDisposable 'Pool' by hand from its injected root IServiceProvider (GetService/GetRequiredService — the service-locator anti-pattern): the root provider tracks every IDisposable it resolves and frees them only at application shutdown, so each call leaks a transient that should be scope-lived — resolve it from an IServiceScope instead (Wrap -> Mid -> Pool) [singleton registered at reg.cs:7]", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [ + [ + "reg.cs", + 7, + "registration of singleton 'Wrap'" + ] + ], + "flow": [ + [ + "reg.cs", + 7, + "singleton 'Wrap' (captor)" + ], + [ + "reg.cs", + 8, + "via 'Mid'" + ], + [ + "reg.cs", + 9, + "leaks transient IDisposable 'Pool'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "reg.cs", + "line": 10, + "code": "DI004", + "component": "NoSite", + "event": "Conn", + "handler": "", + "message": "singleton 'NoSite' resolves transient IDisposable 'Conn' by hand from its injected root IServiceProvider (GetService/GetRequiredService — the service-locator anti-pattern): the root provider tracks every IDisposable it resolves and frees them only at application shutdown, so each call leaks a transient that should be scope-lived — resolve it from an IServiceScope instead (NoSite -> Conn)", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [ + [ + "reg.cs", + 10, + "singleton 'NoSite' (captor)" + ], + [ + "reg.cs", + 6, + "leaks transient IDisposable 'Conn'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di005_cache_site_anchor.facts.json b/tests/fixtures/verdicts/verdict_di005_cache_site_anchor.facts.json new file mode 100644 index 00000000..7d612df5 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di005_cache_site_anchor.facts.json @@ -0,0 +1,90 @@ +{ + "ownir_version": 0, + "module": "Di005", + "components": [], + "_doc": [ + "DI005 anchors at the field-STORE site of the cached entry: Db cached directly", + "at Cache.cs:21; the scoped Db reached through the cached transient Uow anchors", + "at Uow's store site (Report.cs:30); NoSite falls back to its registration", + "(reg.cs:9); Fine caches a transient that reaches no scoped service -> silent." + ], + "services": [ + { + "name": "Cache", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [], + "scope_cached": [ + "Db" + ], + "scope_cache_sites": [ + { + "type": "Db", + "file": "Cache.cs", + "line": 21 + } + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 6, + "deps": [] + }, + { + "name": "Report", + "lifetime": "singleton", + "file": "reg.cs", + "line": 7, + "deps": [], + "scope_cached": [ + "Uow" + ], + "scope_cache_sites": [ + { + "type": "Uow", + "file": "Report.cs", + "line": 30 + } + ] + }, + { + "name": "Uow", + "lifetime": "transient", + "file": "reg.cs", + "line": 8, + "deps": [ + "Db" + ] + }, + { + "name": "NoSite", + "lifetime": "singleton", + "file": "reg.cs", + "line": 9, + "deps": [], + "scope_cached": [ + "Db" + ] + }, + { + "name": "Fine", + "lifetime": "singleton", + "file": "reg.cs", + "line": 10, + "deps": [], + "scope_cached": [ + "Clock" + ] + }, + { + "name": "Clock", + "lifetime": "transient", + "file": "reg.cs", + "line": 11, + "deps": [] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di005_cache_site_anchor.verdicts.json b/tests/fixtures/verdicts/verdict_di005_cache_site_anchor.verdicts.json new file mode 100644 index 00000000..d4c99192 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di005_cache_site_anchor.verdicts.json @@ -0,0 +1,103 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Cache.cs", + "line": 21, + "code": "DI005", + "component": "Cache", + "event": "Db", + "handler": "", + "message": "singleton 'Cache' caches scoped service 'Db', resolved from a scope it creates, into a field: the scope is disposed when the operation ends, so the cached instance dangles (use-after-dispose) and 'Db' is promoted to application lifetime — the captive the scope was meant to avoid. Resolve it inside the scope per use and do not cache it (Cache -> Db) [singleton registered at reg.cs:5]", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [ + [ + "reg.cs", + 5, + "registration of singleton 'Cache'" + ] + ], + "flow": [ + [ + "reg.cs", + 5, + "singleton 'Cache' (captor)" + ], + [ + "reg.cs", + 6, + "caches scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Report.cs", + "line": 30, + "code": "DI005", + "component": "Report", + "event": "Db", + "handler": "", + "message": "singleton 'Report' caches scoped service 'Db', resolved from a scope it creates, into a field: the scope is disposed when the operation ends, so the cached instance dangles (use-after-dispose) and 'Db' is promoted to application lifetime — the captive the scope was meant to avoid. Resolve it inside the scope per use and do not cache it (Report -> Uow -> Db) [singleton registered at reg.cs:7]", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [ + [ + "reg.cs", + 7, + "registration of singleton 'Report'" + ] + ], + "flow": [ + [ + "reg.cs", + 7, + "singleton 'Report' (captor)" + ], + [ + "reg.cs", + 8, + "via 'Uow'" + ], + [ + "reg.cs", + 6, + "caches scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "reg.cs", + "line": 9, + "code": "DI005", + "component": "NoSite", + "event": "Db", + "handler": "", + "message": "singleton 'NoSite' caches scoped service 'Db', resolved from a scope it creates, into a field: the scope is disposed when the operation ends, so the cached instance dangles (use-after-dispose) and 'Db' is promoted to application lifetime — the captive the scope was meant to avoid. Resolve it inside the scope per use and do not cache it (NoSite -> Db)", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [ + [ + "reg.cs", + 9, + "singleton 'NoSite' (captor)" + ], + [ + "reg.cs", + 6, + "caches scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di_duplicate_sites_last_wins.facts.json b/tests/fixtures/verdicts/verdict_di_duplicate_sites_last_wins.facts.json new file mode 100644 index 00000000..e10b8921 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di_duplicate_sites_last_wins.facts.json @@ -0,0 +1,141 @@ +{ + "ownir_version": 0, + "module": "DiDup", + "components": [], + "_doc": [ + "Duplicate site records for the SAME entry type are last-wins (the reference", + "builds the site lookup with a dict comprehension): Twice/Cached anchor at", + "second.*; Trailing's last site has line 0, so last-wins picks it and the < 1", + "guard falls back to the registration (reg.cs:9) — a first-wins or a", + "first-valid-wins port would anchor at real.cs:50; Other's sites are keyed by", + "type, so the wrong-typed first entry is not the anchor (right.cs:60).", + "Negative's last site carries line -2: not a 1-based coordinate, so the anchor falls back to the registration (reg.cs:13) exactly like a line-0 site." + ], + "services": [ + { + "name": "Twice", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [], + "root_resolves": [ + "Conn" + ], + "root_resolve_sites": [ + { + "type": "Conn", + "file": "first.cs", + "line": 10 + }, + { + "type": "Conn", + "file": "second.cs", + "line": 20 + } + ] + }, + { + "name": "Conn", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "deps": [], + "disposable": true + }, + { + "name": "Cached", + "lifetime": "singleton", + "file": "reg.cs", + "line": 7, + "deps": [], + "scope_cached": [ + "Db" + ], + "scope_cache_sites": [ + { + "type": "Db", + "file": "first.cs", + "line": 30 + }, + { + "type": "Db", + "file": "second.cs", + "line": 40 + } + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 8, + "deps": [] + }, + { + "name": "Trailing", + "lifetime": "singleton", + "file": "reg.cs", + "line": 9, + "deps": [], + "root_resolves": [ + "Conn" + ], + "root_resolve_sites": [ + { + "type": "Conn", + "file": "real.cs", + "line": 50 + }, + { + "type": "Conn", + "file": "zero.cs", + "line": 0 + } + ] + }, + { + "name": "Other", + "lifetime": "singleton", + "file": "reg.cs", + "line": 11, + "deps": [], + "scope_cached": [ + "Db" + ], + "scope_cache_sites": [ + { + "type": "Other", + "file": "wrong.cs", + "line": 1 + }, + { + "type": "Db", + "file": "right.cs", + "line": 60 + } + ] + }, + { + "name": "Negative", + "lifetime": "singleton", + "file": "reg.cs", + "line": 13, + "deps": [], + "root_resolves": [ + "Conn" + ], + "root_resolve_sites": [ + { + "type": "Conn", + "file": "real.cs", + "line": 50 + }, + { + "type": "Conn", + "file": "neg.cs", + "line": -2 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di_duplicate_sites_last_wins.verdicts.json b/tests/fixtures/verdicts/verdict_di_duplicate_sites_last_wins.verdicts.json new file mode 100644 index 00000000..b4275079 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di_duplicate_sites_last_wins.verdicts.json @@ -0,0 +1,158 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "reg.cs", + "line": 9, + "code": "DI004", + "component": "Trailing", + "event": "Conn", + "handler": "", + "message": "singleton 'Trailing' resolves transient IDisposable 'Conn' by hand from its injected root IServiceProvider (GetService/GetRequiredService — the service-locator anti-pattern): the root provider tracks every IDisposable it resolves and frees them only at application shutdown, so each call leaks a transient that should be scope-lived — resolve it from an IServiceScope instead (Trailing -> Conn)", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [ + [ + "reg.cs", + 9, + "singleton 'Trailing' (captor)" + ], + [ + "reg.cs", + 6, + "leaks transient IDisposable 'Conn'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "reg.cs", + "line": 13, + "code": "DI004", + "component": "Negative", + "event": "Conn", + "handler": "", + "message": "singleton 'Negative' resolves transient IDisposable 'Conn' by hand from its injected root IServiceProvider (GetService/GetRequiredService — the service-locator anti-pattern): the root provider tracks every IDisposable it resolves and frees them only at application shutdown, so each call leaks a transient that should be scope-lived — resolve it from an IServiceScope instead (Negative -> Conn)", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [ + [ + "reg.cs", + 13, + "singleton 'Negative' (captor)" + ], + [ + "reg.cs", + 6, + "leaks transient IDisposable 'Conn'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "right.cs", + "line": 60, + "code": "DI005", + "component": "Other", + "event": "Db", + "handler": "", + "message": "singleton 'Other' caches scoped service 'Db', resolved from a scope it creates, into a field: the scope is disposed when the operation ends, so the cached instance dangles (use-after-dispose) and 'Db' is promoted to application lifetime — the captive the scope was meant to avoid. Resolve it inside the scope per use and do not cache it (Other -> Db) [singleton registered at reg.cs:11]", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [ + [ + "reg.cs", + 11, + "registration of singleton 'Other'" + ] + ], + "flow": [ + [ + "reg.cs", + 11, + "singleton 'Other' (captor)" + ], + [ + "reg.cs", + 8, + "caches scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "second.cs", + "line": 20, + "code": "DI004", + "component": "Twice", + "event": "Conn", + "handler": "", + "message": "singleton 'Twice' resolves transient IDisposable 'Conn' by hand from its injected root IServiceProvider (GetService/GetRequiredService — the service-locator anti-pattern): the root provider tracks every IDisposable it resolves and frees them only at application shutdown, so each call leaks a transient that should be scope-lived — resolve it from an IServiceScope instead (Twice -> Conn) [singleton registered at reg.cs:5]", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [ + [ + "reg.cs", + 5, + "registration of singleton 'Twice'" + ] + ], + "flow": [ + [ + "reg.cs", + 5, + "singleton 'Twice' (captor)" + ], + [ + "reg.cs", + 6, + "leaks transient IDisposable 'Conn'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "second.cs", + "line": 40, + "code": "DI005", + "component": "Cached", + "event": "Db", + "handler": "", + "message": "singleton 'Cached' caches scoped service 'Db', resolved from a scope it creates, into a field: the scope is disposed when the operation ends, so the cached instance dangles (use-after-dispose) and 'Db' is promoted to application lifetime — the captive the scope was meant to avoid. Resolve it inside the scope per use and do not cache it (Cached -> Db) [singleton registered at reg.cs:7]", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [ + [ + "reg.cs", + 7, + "registration of singleton 'Cached'" + ] + ], + "flow": [ + [ + "reg.cs", + 7, + "singleton 'Cached' (captor)" + ], + [ + "reg.cs", + 8, + "caches scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di_graph_families.facts.json b/tests/fixtures/verdicts/verdict_di_graph_families.facts.json new file mode 100644 index 00000000..50121f85 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di_graph_families.facts.json @@ -0,0 +1,80 @@ +{ + "ownir_version": 0, + "module": "DiGraph", + "components": [], + "_doc": [ + "DI001/DI002/DI003 through real services[] facts, in the bridge's append order", + "and the registration-site anchor: App captures scoped Db directly AND through", + "the transient Mid (reported once), and captures the transient IDisposable Conn", + "through Mid (DI003); Weak holds Db weakly (DI002); Outer -> Inner (singleton) ->", + "Db is Inner's own DI001, not Outer's." + ], + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [ + "Db", + "Mid" + ], + "ctor_file": "App.cs", + "ctor_line": 3, + "ctor_type": "App" + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 6, + "deps": [] + }, + { + "name": "Mid", + "lifetime": "transient", + "file": "reg.cs", + "line": 7, + "deps": [ + "Db", + "Conn" + ] + }, + { + "name": "Conn", + "lifetime": "transient", + "file": "reg.cs", + "line": 8, + "deps": [], + "disposable": true + }, + { + "name": "Weak", + "lifetime": "singleton", + "file": "reg.cs", + "line": 9, + "deps": [], + "weak_deps": [ + "Db" + ] + }, + { + "name": "Outer", + "lifetime": "singleton", + "file": "reg.cs", + "line": 10, + "deps": [ + "Inner" + ] + }, + { + "name": "Inner", + "lifetime": "singleton", + "file": "reg.cs", + "line": 11, + "deps": [ + "Db" + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di_graph_families.verdicts.json b/tests/fixtures/verdicts/verdict_di_graph_families.verdicts.json new file mode 100644 index 00000000..72059fc8 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di_graph_families.verdicts.json @@ -0,0 +1,130 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "reg.cs", + "line": 5, + "code": "DI001", + "component": "App", + "event": "Db", + "handler": "", + "message": "singleton 'App' captures scoped service 'Db' (captive dependency: App -> Db) [consumed by the 'App' constructor at App.cs:3]", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [ + [ + "App.cs", + 3, + "consuming constructor of 'App'" + ] + ], + "flow": [ + [ + "reg.cs", + 5, + "singleton 'App' (captor)" + ], + [ + "reg.cs", + 6, + "captures scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "reg.cs", + "line": 5, + "code": "DI003", + "component": "App", + "event": "Conn", + "handler": "", + "message": "singleton 'App' captures transient IDisposable 'Conn': it is promoted to application lifetime and disposed only when the root provider is disposed (App -> Mid -> Conn) [consumed by the 'App' constructor at App.cs:3]", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [ + [ + "App.cs", + 3, + "consuming constructor of 'App'" + ] + ], + "flow": [ + [ + "reg.cs", + 5, + "singleton 'App' (captor)" + ], + [ + "reg.cs", + 7, + "via 'Mid'" + ], + [ + "reg.cs", + 8, + "captures transient IDisposable 'Conn'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "reg.cs", + "line": 9, + "code": "DI002", + "component": "Weak", + "event": "Db", + "handler": "", + "message": "singleton 'Weak' weakly captures scoped service 'Db' (WeakReference): 'Db' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (Weak -> Db)", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [ + [ + "reg.cs", + 9, + "singleton 'Weak' (captor)" + ], + [ + "reg.cs", + 6, + "weakly captures scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "reg.cs", + "line": 11, + "code": "DI001", + "component": "Inner", + "event": "Db", + "handler": "", + "message": "singleton 'Inner' captures scoped service 'Db' (captive dependency: Inner -> Db)", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "reg.cs", + 11, + "singleton 'Inner' (captor)" + ], + [ + "reg.cs", + 6, + "captures scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di_tolerant_typed_coercions.facts.json b/tests/fixtures/verdicts/verdict_di_tolerant_typed_coercions.facts.json new file mode 100644 index 00000000..9cfa7340 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di_tolerant_typed_coercions.facts.json @@ -0,0 +1,61 @@ +{ + "ownir_version": 0, + "module": "DiTolerant", + "components": [], + "_doc": [ + "BR-P1 coercions on the tolerant door, restricted to shapes the typed Rust", + "door also constructs: a missing registration file/line reads as ('?', 0);", + "`disposable` counts ONLY as the JSON boolean true (a string 'true' or an int", + "1 is not disposable -> no DI003); a site record without a type never matches,", + "one without a line reads as 0 and falls back to the registration." + ], + "services": [ + { + "name": "App", + "lifetime": "singleton", + "deps": [ + "Conn", + "Db" + ] + }, + { + "name": "Conn", + "lifetime": "transient", + "deps": [], + "disposable": "true" + }, + { + "name": "Db", + "lifetime": "scoped", + "deps": [], + "disposable": 1 + }, + { + "name": "Loc", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [], + "root_resolves": [ + "Real" + ], + "root_resolve_sites": [ + { + "line": 7 + }, + { + "type": "Real", + "file": "Loc.cs" + } + ] + }, + { + "name": "Real", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "deps": [], + "disposable": true + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_di_tolerant_typed_coercions.verdicts.json b/tests/fixtures/verdicts/verdict_di_tolerant_typed_coercions.verdicts.json new file mode 100644 index 00000000..4658308a --- /dev/null +++ b/tests/fixtures/verdicts/verdict_di_tolerant_typed_coercions.verdicts.json @@ -0,0 +1,48 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "?", + "line": 0, + "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": [], + "ignore_reason": null, + "column": null + }, + { + "file": "reg.cs", + "line": 5, + "code": "DI004", + "component": "Loc", + "event": "Real", + "handler": "", + "message": "singleton 'Loc' resolves transient IDisposable 'Real' by hand from its injected root IServiceProvider (GetService/GetRequiredService — the service-locator anti-pattern): the root provider tracks every IDisposable it resolves and frees them only at application shutdown, so each call leaks a transient that should be scope-lived — resolve it from an IServiceScope instead (Loc -> Real)", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [ + [ + "reg.cs", + 5, + "singleton 'Loc' (captor)" + ], + [ + "reg.cs", + 6, + "leaks transient IDisposable 'Real'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_door_effect_deps_not_strings.facts.json b/tests/fixtures/verdicts/verdict_door_effect_deps_not_strings.facts.json new file mode 100644 index 00000000..e3510dc1 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_door_effect_deps_not_strings.facts.json @@ -0,0 +1,45 @@ +{ + "ownir_version": 0, + "module": "Door", + "components": [], + "_doc": [ + "#294 OD-1: BR-D2's skip-not-coerce on the tolerant door. The reference SKIPS", + "the effect whose deps is a bare string (no spurious EFF001) and reports the", + "well-formed sibling; the Rust tolerant entry is the typed constructor, which", + "refuses the document before the bridge's skip rule can run." + ], + "effects": [ + { + "component": "X", + "file": "X.tsx", + "line": 1, + "io": true, + "deps": "a", + "bindings": [ + { + "name": "a", + "init": "object", + "refs": [], + "line": 1 + } + ] + }, + { + "component": "Ok", + "file": "Ok.tsx", + "line": 9, + "io": true, + "deps": [ + "o" + ], + "bindings": [ + { + "name": "o", + "init": "object", + "refs": [], + "line": 8 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_door_effect_deps_not_strings.verdicts.json b/tests/fixtures/verdicts/verdict_door_effect_deps_not_strings.verdicts.json new file mode 100644 index 00000000..2394ad0b --- /dev/null +++ b/tests/fixtures/verdicts/verdict_door_effect_deps_not_strings.verdicts.json @@ -0,0 +1,32 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Ok.tsx", + "line": 9, + "code": "EFF001", + "component": "Ok", + "event": "o", + "handler": "", + "message": "effect re-runs on every render: dependency 'o' 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 'o' with useMemo/useCallback (or move it out of render)", + "kind": "react effect", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Ok.tsx", + 9, + "effect re-runs here on 'o'" + ], + [ + "Ok.tsx", + 8, + "'o' gets a fresh identity here — stabilise with useMemo" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_door_service_unknown_lifetime.facts.json b/tests/fixtures/verdicts/verdict_door_service_unknown_lifetime.facts.json new file mode 100644 index 00000000..4ecfd778 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_door_service_unknown_lifetime.facts.json @@ -0,0 +1,36 @@ +{ + "ownir_version": 0, + "module": "Door", + "components": [], + "_doc": [ + "#294 OD-1: an unknown DI lifetime is IGNORED by the reference finders on the", + "tolerant door (Mystery is neither scoped nor transient, so App -> Db is the", + "only DI001); the typed Rust door refuses the document at the lifetime enum." + ], + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [ + "Mystery", + "Db" + ] + }, + { + "name": "Mystery", + "lifetime": "prototype", + "file": "reg.cs", + "line": 6, + "deps": [] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 7, + "deps": [] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_door_service_unknown_lifetime.verdicts.json b/tests/fixtures/verdicts/verdict_door_service_unknown_lifetime.verdicts.json new file mode 100644 index 00000000..c8cee711 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_door_service_unknown_lifetime.verdicts.json @@ -0,0 +1,32 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "reg.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": [ + [ + "reg.cs", + 5, + "singleton 'App' (captor)" + ], + [ + "reg.cs", + 7, + "captures scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_eff001_storm_and_memo.facts.json b/tests/fixtures/verdicts/verdict_eff001_storm_and_memo.facts.json new file mode 100644 index 00000000..ff9889b9 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_eff001_storm_and_memo.facts.json @@ -0,0 +1,99 @@ +{ + "ownir_version": 0, + "module": "Fx", + "components": [], + "_doc": [ + "EFF001 through real effects[] facts: a fresh-object dep re-fires (storm), a", + "memoised dep is clean, instability propagates through a derivation chain, and", + "an effect without IO never fires. Anchors are the effect's own (file, line);", + "the list sorts by file first (Chart.tsx before Dashboard.tsx)." + ], + "effects": [ + { + "component": "Dashboard", + "file": "Dashboard.tsx", + "line": 33, + "io": true, + "deps": [ + "filters" + ], + "bindings": [ + { + "name": "filters", + "init": "object", + "refs": [], + "line": 32 + } + ] + }, + { + "component": "Dashboard", + "file": "Dashboard.tsx", + "line": 40, + "io": true, + "deps": [ + "stable" + ], + "bindings": [ + { + "name": "stable", + "init": "memo", + "refs": [ + "x" + ], + "line": 39 + } + ] + }, + { + "component": "Chart", + "file": "Chart.tsx", + "line": 12, + "io": true, + "deps": [ + "c" + ], + "bindings": [ + { + "name": "a", + "init": "array", + "refs": [], + "line": 9 + }, + { + "name": "b", + "init": "ident", + "refs": [ + "a" + ], + "line": 10 + }, + { + "name": "c", + "init": "ident", + "refs": [ + "b" + ], + "line": 11 + } + ] + }, + { + "component": "Quiet", + "file": "Quiet.tsx", + "line": 5, + "io": false, + "deps": [ + "o" + ], + "bindings": [ + { + "name": "o", + "init": "object", + "refs": [], + "line": 4 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_eff001_storm_and_memo.verdicts.json b/tests/fixtures/verdicts/verdict_eff001_storm_and_memo.verdicts.json new file mode 100644 index 00000000..5063e6e8 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_eff001_storm_and_memo.verdicts.json @@ -0,0 +1,59 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Chart.tsx", + "line": 12, + "code": "EFF001", + "component": "Chart", + "event": "c", + "handler": "", + "message": "effect re-runs on every render: dependency 'c' derives from 'a', an array literal created in render scope (via c -> b -> a), so its identity changes on every render; the effect performs IO, which can become a request storm — stabilise 'a' with useMemo/useCallback (or move it out of render)", + "kind": "react effect", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "Chart.tsx", + 12, + "effect re-runs here on 'c'" + ], + [ + "Chart.tsx", + 9, + "'a' gets a fresh identity here — stabilise with useMemo" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "Dashboard.tsx", + "line": 33, + "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": [ + [ + "Dashboard.tsx", + 33, + "effect re-runs here on 'filters'" + ], + [ + "Dashboard.tsx", + 32, + "'filters' gets a fresh identity here — stabilise with useMemo" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_multi_file_ordering.facts.json b/tests/fixtures/verdicts/verdict_multi_file_ordering.facts.json new file mode 100644 index 00000000..d61e0c4a --- /dev/null +++ b/tests/fixtures/verdicts/verdict_multi_file_ordering.facts.json @@ -0,0 +1,101 @@ +{ + "ownir_version": 0, + "module": "Order", + "_doc": [ + "BR-V8 across families and files: five findings on line 7 in five files sort", + "by file first (a.tsx EFF001, b.cs OWN001, f.cs OWN001+OWN002, m.cs DI001,", + "z.cs OWN001); within f.cs:7 two flow-local verdicts with no column sort by", + "code (the leak of `s` before the use-after-release of `r`)." + ], + "components": [ + { + "name": "Z", + "file": "z.cs", + "subscriptions": [ + { + "event": "E", + "handler": "H", + "line": 7, + "released": false + } + ] + }, + { + "name": "B", + "file": "b.cs", + "subscriptions": [ + { + "event": "E", + "handler": "H", + "line": 7, + "released": false + } + ] + } + ], + "services": [ + { + "name": "M", + "lifetime": "singleton", + "file": "m.cs", + "line": 7, + "deps": [ + "S" + ] + }, + { + "name": "S", + "lifetime": "scoped", + "file": "m.cs", + "line": 8, + "deps": [] + } + ], + "effects": [ + { + "component": "A", + "file": "a.tsx", + "line": 7, + "io": true, + "deps": [ + "o" + ], + "bindings": [ + { + "name": "o", + "init": "object", + "refs": [], + "line": 6 + } + ] + } + ], + "functions": [ + { + "name": "Order.F", + "file": "f.cs", + "body": [ + { + "op": "acquire", + "var": "s", + "line": 7 + }, + { + "op": "acquire", + "var": "r", + "line": 7 + }, + { + "op": "release", + "var": "r", + "line": 7 + }, + { + "op": "use", + "var": "r", + "line": 7 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_multi_file_ordering.verdicts.json b/tests/fixtures/verdicts/verdict_multi_file_ordering.verdicts.json new file mode 100644 index 00000000..79bfaff4 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_multi_file_ordering.verdicts.json @@ -0,0 +1,123 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "a.tsx", + "line": 7, + "code": "EFF001", + "component": "A", + "event": "o", + "handler": "", + "message": "effect re-runs on every render: dependency 'o' 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 'o' with useMemo/useCallback (or move it out of render)", + "kind": "react effect", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "a.tsx", + 7, + "effect re-runs here on 'o'" + ], + [ + "a.tsx", + 6, + "'o' gets a fresh identity here — stabilise with useMemo" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "b.cs", + "line": 7, + "code": "OWN001", + "component": "B", + "event": "E", + "handler": "H", + "message": "event 'E' is subscribed (handler 'H') but never unsubscribed — the source keeps 'B' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "f.cs", + "line": 7, + "code": "OWN001", + "component": "Order.F", + "event": "s", + "handler": "", + "message": "IDisposable local 's' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "f.cs", + "line": 7, + "code": "OWN002", + "component": "Order.F", + "event": "r", + "handler": "", + "message": "IDisposable local 'r' is used after it is disposed", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "m.cs", + "line": 7, + "code": "DI001", + "component": "M", + "event": "S", + "handler": "", + "message": "singleton 'M' captures scoped service 'S' (captive dependency: M -> S)", + "kind": "DI lifetime", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "m.cs", + 7, + "singleton 'M' (captor)" + ], + [ + "m.cs", + 8, + "captures scoped service 'S'" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "z.cs", + "line": 7, + "code": "OWN001", + "component": "Z", + "event": "E", + "handler": "H", + "message": "event 'E' is subscribed (handler 'H') but never unsubscribed — the source keeps 'Z' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_own051_gates.facts.json b/tests/fixtures/verdicts/verdict_own051_gates.facts.json new file mode 100644 index 00000000..a9ee0a1e --- /dev/null +++ b/tests/fixtures/verdicts/verdict_own051_gates.facts.json @@ -0,0 +1,120 @@ +{ + "ownir_version": 0, + "module": "Gates", + "_doc": [ + "BR-L8 / INF-A5 advisories: an owned local dropped after a may-call is silent", + "plus one OWN051 (drop_user); a never-acquired value at a may-position earns", + "no note (plain_user); a pre-call use-after-release keeps its OWN002 beside", + "the OWN051 and the post-call region stays uncharged (pre_bug); a BCL", + "fresh-factory result counts as owned here (factory_user)." + ], + "functions": [ + { + "name": "Gates.cond_rel", + "file": "U.cs", + "params": [ + { + "name": "x", + "line": 1 + } + ], + "body": [ + { + "op": "if", + "line": 2, + "then": [ + { + "op": "release", + "var": "x", + "line": 3 + } + ], + "else": [] + } + ] + }, + { + "name": "Gates.drop_user", + "file": "U.cs", + "body": [ + { + "op": "acquire", + "var": "r", + "line": 10 + }, + { + "op": "call", + "callee": "Gates.cond_rel", + "args": [ + "r" + ], + "line": 11 + } + ] + }, + { + "name": "Gates.plain_user", + "file": "U.cs", + "body": [ + { + "op": "call", + "callee": "Gates.cond_rel", + "args": [ + "v" + ], + "line": 21 + } + ] + }, + { + "name": "Gates.pre_bug", + "file": "U.cs", + "body": [ + { + "op": "acquire", + "var": "r", + "line": 30 + }, + { + "op": "release", + "var": "r", + "line": 31 + }, + { + "op": "use", + "var": "r", + "line": 32 + }, + { + "op": "call", + "callee": "Gates.cond_rel", + "args": [ + "r" + ], + "line": 33 + } + ] + }, + { + "name": "Gates.factory_user", + "file": "U.cs", + "body": [ + { + "op": "call", + "callee": "System.IO.File.OpenRead", + "args": [], + "result": "f", + "line": 40 + }, + { + "op": "call", + "callee": "Gates.cond_rel", + "args": [ + "f" + ], + "line": 41 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_own051_gates.verdicts.json b/tests/fixtures/verdicts/verdict_own051_gates.verdicts.json new file mode 100644 index 00000000..3b7e0e52 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_own051_gates.verdicts.json @@ -0,0 +1,80 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "U.cs", + "line": 11, + "code": "OWN051", + "component": "Gates.drop_user", + "event": "r", + "handler": "Gates.cond_rel", + "message": "cannot verify whether 'Gates.cond_rel' takes ownership of 'r' (inferred contract: may); optimistically assuming it does — 'r' is not checked past this call", + "kind": "ownership transfer", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "U.cs", + "line": 30, + "code": "OWN002", + "component": "Gates.pre_bug", + "event": "r", + "handler": "", + "message": "IDisposable local 'r' is used after it is disposed", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "U.cs", + 30, + "acquired 'r' here" + ], + [ + "U.cs", + 32, + "used here after it was released/returned" + ] + ], + "ignore_reason": null, + "column": null + }, + { + "file": "U.cs", + "line": 33, + "code": "OWN051", + "component": "Gates.pre_bug", + "event": "r", + "handler": "Gates.cond_rel", + "message": "cannot verify whether 'Gates.cond_rel' takes ownership of 'r' (inferred contract: may); optimistically assuming it does — 'r' is not checked past this call", + "kind": "ownership transfer", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "U.cs", + "line": 41, + "code": "OWN051", + "component": "Gates.factory_user", + "event": "f", + "handler": "Gates.cond_rel", + "message": "cannot verify whether 'Gates.cond_rel' takes ownership of 'f' (inferred contract: may); optimistically assuming it does — 'f' is not checked past this call", + "kind": "ownership transfer", + "advisory": true, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_pool_view_anchor.facts.json b/tests/fixtures/verdicts/verdict_pool_view_anchor.facts.json new file mode 100644 index 00000000..f5cd45f4 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_pool_view_anchor.facts.json @@ -0,0 +1,31 @@ +{ + "ownir_version": 0, + "module": "PoolView", + "_doc": [ + "OWN025 anchors at the VIEW site (the core's line) with column null ON", + "PURPOSE — the acquire's column belongs to a different node — and is tagged a", + "pooled buffer; the never-returned rent is an OWN001 at the Rent site with its", + "own column, tagged pooled buffer too." + ], + "functions": [ + { + "name": "PoolView.F", + "file": "P.cs", + "body": [ + { + "op": "acquire", + "var": "b", + "kind": "pool", + "line": 10, + "column": 5 + }, + { + "op": "overspan", + "var": "b", + "line": 12, + "column": 9 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_pool_view_anchor.verdicts.json b/tests/fixtures/verdicts/verdict_pool_view_anchor.verdicts.json new file mode 100644 index 00000000..c7a21ff0 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_pool_view_anchor.verdicts.json @@ -0,0 +1,48 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "P.cs", + "line": 10, + "code": "OWN001", + "component": "PoolView.F", + "event": "b", + "handler": "", + "message": "pooled buffer 'b' is rented but never returned to the pool (leak)", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": 5 + }, + { + "file": "P.cs", + "line": 12, + "code": "OWN025", + "component": "PoolView.F", + "event": "b", + "handler": "", + "message": "pooled buffer 'b' is viewed at its full length, past the logical length it was rented for (over-read / over-clear)", + "kind": "pooled buffer", + "advisory": false, + "severity": null, + "related": [], + "flow": [ + [ + "P.cs", + 10, + "rented 'b' here" + ], + [ + "P.cs", + 12, + "viewed here at full length, past what it was rented for" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_same_site_distinct_events.facts.json b/tests/fixtures/verdicts/verdict_same_site_distinct_events.facts.json new file mode 100644 index 00000000..a6a741ad --- /dev/null +++ b/tests/fixtures/verdicts/verdict_same_site_distinct_events.facts.json @@ -0,0 +1,106 @@ +{ + "ownir_version": 0, + "module": "SameSite", + "_doc": [ + "BR-V7's key, one pair per member: records on the same line that differ in", + "exactly ONE key member stay two findings — event only (bus.A/OnA vs", + "bus.C/OnA), handler only (bus.A/OnA vs bus.A/OnZ), component only (Vm vs Vm2,", + "same file), kind only (a timer and a disposable field named _t on line 7),", + "severity only (bus.S from an injected vs a static source on line 6),", + "ignore_reason only (bus.R reviewed vs not, line 8) — while an exact repeat", + "of the first record (the third entry) folds into one finding." + ], + "components": [ + { + "name": "Vm", + "file": "S.cs", + "subscriptions": [ + { + "event": "bus.A", + "handler": "OnA", + "line": 5, + "released": false + }, + { + "event": "bus.B", + "handler": "OnB", + "line": 5, + "released": false + }, + { + "event": "bus.A", + "handler": "OnA", + "line": 5, + "released": false + }, + { + "event": "bus.C", + "handler": "OnA", + "line": 5, + "released": false + }, + { + "event": "bus.A", + "handler": "OnZ", + "line": 5, + "released": false + }, + { + "event": "bus.S", + "handler": "OnS", + "line": 6, + "released": false, + "resource": "subscription", + "source": "injected" + }, + { + "event": "bus.S", + "handler": "OnS", + "line": 6, + "released": false, + "resource": "subscription", + "source": "static" + }, + { + "event": "_t", + "handler": "", + "line": 7, + "released": false, + "resource": "timer" + }, + { + "event": "_t", + "handler": "", + "line": 7, + "released": false, + "resource": "disposable" + }, + { + "event": "bus.R", + "handler": "OnR", + "line": 8, + "released": false, + "ignore_reason": "reviewed" + }, + { + "event": "bus.R", + "handler": "OnR", + "line": 8, + "released": false + } + ] + }, + { + "name": "Vm2", + "file": "S.cs", + "subscriptions": [ + { + "event": "bus.A", + "handler": "OnA", + "line": 5, + "released": false + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_same_site_distinct_events.verdicts.json b/tests/fixtures/verdicts/verdict_same_site_distinct_events.verdicts.json new file mode 100644 index 00000000..670fba0c --- /dev/null +++ b/tests/fixtures/verdicts/verdict_same_site_distinct_events.verdicts.json @@ -0,0 +1,181 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "S.cs", + "line": 5, + "code": "OWN001", + "component": "Vm2", + "event": "bus.A", + "handler": "OnA", + "message": "event 'bus.A' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm2' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 5, + "code": "OWN001", + "component": "Vm", + "event": "bus.A", + "handler": "OnA", + "message": "event 'bus.A' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 5, + "code": "OWN001", + "component": "Vm", + "event": "bus.B", + "handler": "OnB", + "message": "event 'bus.B' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 5, + "code": "OWN001", + "component": "Vm", + "event": "bus.C", + "handler": "OnA", + "message": "event 'bus.C' is subscribed (handler 'OnA') but never unsubscribed — the source keeps 'Vm' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 5, + "code": "OWN001", + "component": "Vm", + "event": "bus.A", + "handler": "OnZ", + "message": "event 'bus.A' is subscribed (handler 'OnZ') but never unsubscribed — the source keeps 'Vm' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 6, + "code": "OWN001", + "component": "Vm", + "event": "bus.S", + "handler": "OnS", + "message": "event 'bus.S' is subscribed (handler 'OnS') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 6, + "code": "OWN001", + "component": "Vm", + "event": "bus.S", + "handler": "OnS", + "message": "event 'bus.S' is subscribed (handler 'OnS') but never unsubscribed — the source keeps 'Vm' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 7, + "code": "OWN001", + "component": "Vm", + "event": "_t", + "handler": "", + "message": "timer '_t' (handler '') is started but never stopped or detached — the running timer keeps 'Vm' alive (leak)", + "kind": "timer", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 7, + "code": "OWN001", + "component": "Vm", + "event": "_t", + "handler": "", + "message": "IDisposable field '_t' is never disposed — its owner 'Vm' leaks it (leak)", + "kind": "disposable field", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 8, + "code": "OWN001", + "component": "Vm", + "event": "bus.R", + "handler": "OnR", + "message": "event 'bus.R' is subscribed (handler 'OnR') but never unsubscribed — the source keeps 'Vm' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": "reviewed", + "column": null + }, + { + "file": "S.cs", + "line": 8, + "code": "OWN001", + "component": "Vm", + "event": "bus.R", + "handler": "OnR", + "message": "event 'bus.R' is subscribed (handler 'OnR') but never unsubscribed — the source keeps 'Vm' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_severity_tiers_and_suppression.facts.json b/tests/fixtures/verdicts/verdict_severity_tiers_and_suppression.facts.json new file mode 100644 index 00000000..1264ecd0 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_severity_tiers_and_suppression.facts.json @@ -0,0 +1,122 @@ +{ + "ownir_version": 0, + "module": "Tiers", + "_doc": [ + "BR-V6: an injected-source `+=` or ignored `.Subscribe()` is the warning tier;", + "a static source, a timer (whatever its source), a disposable field are", + "error-tier (severity null); a non-empty ignore_reason suppresses (still", + "minted, carried) and an empty one does not; `lambda` changes no identity at", + "this surface; a static capture with a reason is a suppressed OWN014; the", + "unresolved marker is an advisory OWN050 carrying its column; DI002 is warning." + ], + "components": [ + { + "name": "Vm", + "file": "Vm.cs", + "subscriptions": [ + { + "event": "bus.A", + "handler": "OnA", + "line": 10, + "released": false, + "resource": "subscription", + "source": "injected" + }, + { + "event": "SystemEvents.B", + "handler": "OnB", + "line": 11, + "released": false, + "resource": "subscription", + "source": "static" + }, + { + "event": "obs.Subscribe", + "handler": "", + "line": 12, + "released": false, + "resource": "subscribe", + "source": "injected" + }, + { + "event": "src.Subscribe", + "handler": "", + "line": 13, + "released": false, + "resource": "subscribe", + "source": "static" + }, + { + "event": "_timer", + "handler": "", + "line": 14, + "released": false, + "resource": "timer", + "source": "injected" + }, + { + "event": "_field", + "handler": "", + "line": 15, + "released": false, + "resource": "disposable", + "type": "System.IO.Stream", + "ignore_reason": "reviewed: disposed by the owner" + }, + { + "event": "ev.C", + "handler": "OnC", + "line": 16, + "released": false, + "resource": "subscription", + "source": "static", + "ignore_reason": "" + }, + { + "event": "ev.D", + "handler": "(s, e) => Go()", + "line": 17, + "released": false, + "resource": "subscription", + "source": "static", + "lambda": true + }, + { + "event": "SystemEvents.E", + "handler": "OnE", + "line": 18, + "released": false, + "resource": "capture", + "source": "static", + "ignore_reason": "legacy" + }, + { + "event": "grid.F", + "handler": "OnF", + "line": 19, + "column": 9, + "resource": "unresolved-subscription" + } + ] + } + ], + "services": [ + { + "name": "Weak", + "lifetime": "singleton", + "file": "reg.cs", + "line": 3, + "deps": [], + "weak_deps": [ + "Db" + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 4, + "deps": [] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_severity_tiers_and_suppression.verdicts.json b/tests/fixtures/verdicts/verdict_severity_tiers_and_suppression.verdicts.json new file mode 100644 index 00000000..3b4bc88f --- /dev/null +++ b/tests/fixtures/verdicts/verdict_severity_tiers_and_suppression.verdicts.json @@ -0,0 +1,192 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "Vm.cs", + "line": 10, + "code": "OWN001", + "component": "Vm", + "event": "bus.A", + "handler": "OnA", + "message": "event 'bus.A' is subscribed (handler 'OnA') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 11, + "code": "OWN001", + "component": "Vm", + "event": "SystemEvents.B", + "handler": "OnB", + "message": "event 'SystemEvents.B' is subscribed (handler 'OnB') but never unsubscribed — the source keeps 'Vm' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 12, + "code": "OWN001", + "component": "Vm", + "event": "obs.Subscribe", + "handler": "", + "message": "the result of 'obs.Subscribe' is ignored — its IDisposable subscription is never disposed; the source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'Vm' alive (possible leak)", + "kind": "subscription token", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 13, + "code": "OWN001", + "component": "Vm", + "event": "src.Subscribe", + "handler": "", + "message": "the result of 'src.Subscribe' is ignored — the IDisposable subscription is never disposed, leaking 'Vm' (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 14, + "code": "OWN001", + "component": "Vm", + "event": "_timer", + "handler": "", + "message": "timer '_timer' (handler '') is started but never stopped or detached — the running timer keeps 'Vm' alive (leak)", + "kind": "timer", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 15, + "code": "OWN001", + "component": "Vm", + "event": "_field", + "handler": "", + "message": "IDisposable field '_field' (type 'System.IO.Stream') is never disposed — its owner 'Vm' leaks it (leak)", + "kind": "disposable field", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": "reviewed: disposed by the owner", + "column": null + }, + { + "file": "Vm.cs", + "line": 16, + "code": "OWN001", + "component": "Vm", + "event": "ev.C", + "handler": "OnC", + "message": "event 'ev.C' is subscribed (handler 'OnC') but never unsubscribed — the source keeps 'Vm' alive (leak)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 17, + "code": "OWN001", + "component": "Vm", + "event": "ev.D", + "handler": "(s, e) => Go()", + "message": "event 'ev.D' is subscribed (handler '(s, e) => Go()') but never unsubscribed — the source keeps 'Vm' alive (leak — and being an inline lambda it has no '-=' handle, so it could never be detached)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "Vm.cs", + "line": 18, + "code": "OWN014", + "component": "Vm", + "event": "SystemEvents.E", + "handler": "OnE", + "message": "event 'SystemEvents.E' is subscribed (handler 'OnE') to a static (process-lived) event source that outlives 'Vm'; the strong subscription promotes 'Vm' to the source's lifetime, so it can never be collected — a region escape (leak, no release path)", + "kind": "subscription token", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": "legacy", + "column": null + }, + { + "file": "Vm.cs", + "line": 19, + "code": "OWN050", + "component": "Vm", + "event": "grid.F", + "handler": "OnF", + "message": "cannot verify 'grid.F' — 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": 9 + }, + { + "file": "reg.cs", + "line": 3, + "code": "DI002", + "component": "Weak", + "event": "Db", + "handler": "", + "message": "singleton 'Weak' weakly captures scoped service 'Db' (WeakReference): 'Db' is still resolved from the root provider and promoted to application lifetime — the weak reference avoids pinning it for the GC but does not fix the captive-dependency lifetime violation (Weak -> Db)", + "kind": "DI lifetime", + "advisory": false, + "severity": "warning", + "related": [], + "flow": [ + [ + "reg.cs", + 3, + "singleton 'Weak' (captor)" + ], + [ + "reg.cs", + 4, + "weakly captures scoped service 'Db'" + ] + ], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_skip_list_artifacts.facts.json b/tests/fixtures/verdicts/verdict_skip_list_artifacts.facts.json new file mode 100644 index 00000000..9d532fd5 --- /dev/null +++ b/tests/fixtures/verdicts/verdict_skip_list_artifacts.facts.json @@ -0,0 +1,121 @@ +{ + "ownir_version": 0, + "module": "Skip", + "_doc": [ + "BR-V2, the CLOSED skip list, one control per member the tree can reach:", + "OWN033 (partial `return s` -> the synthesized owned return type is not", + "returned on the fall-through path), OWN035 (a bare `return` beside a", + "`return s`), OWN034 (a `borrow` parameter handed to a `consume` position),", + "OWN041 (arity mismatch against the callee's contract). Each is a bridge", + "modeling artifact with no subject: the reference skips it and reports only", + "the real leaks beside it; un-skipping any one of them turns the case into a", + "map-or-raise refusal. OWN040 is unreachable by construction (the lowerer", + "drops an unresolvable callee before the core could see it)." + ], + "functions": [ + { + "name": "Skip.partial", + "file": "S.cs", + "body": [ + { + "op": "acquire", + "var": "s", + "line": 2 + }, + { + "op": "if", + "line": 3, + "then": [ + { + "op": "return", + "var": "s", + "line": 4 + } + ], + "else": [] + } + ] + }, + { + "name": "Skip.bare", + "file": "S.cs", + "body": [ + { + "op": "acquire", + "var": "t", + "line": 12 + }, + { + "op": "if", + "line": 13, + "then": [ + { + "op": "return", + "var": "t", + "line": 14 + } + ], + "else": [ + { + "op": "return", + "line": 15 + } + ] + } + ] + }, + { + "name": "Skip.take", + "file": "S.cs", + "params": [ + { + "name": "x", + "line": 21, + "effect": "consume" + } + ], + "body": [] + }, + { + "name": "Skip.borrower", + "file": "S.cs", + "params": [ + { + "name": "b", + "line": 31, + "effect": "borrow" + } + ], + "body": [ + { + "op": "call", + "callee": "Skip.take", + "args": [ + "b" + ], + "line": 32 + } + ] + }, + { + "name": "Skip.arity", + "file": "S.cs", + "body": [ + { + "op": "acquire", + "var": "u", + "line": 41 + }, + { + "op": "call", + "callee": "Skip.take", + "args": [ + "u", + "u" + ], + "line": 42 + } + ] + } + ] +} diff --git a/tests/fixtures/verdicts/verdict_skip_list_artifacts.verdicts.json b/tests/fixtures/verdicts/verdict_skip_list_artifacts.verdicts.json new file mode 100644 index 00000000..508ecb3a --- /dev/null +++ b/tests/fixtures/verdicts/verdict_skip_list_artifacts.verdicts.json @@ -0,0 +1,69 @@ +{ + "verdicts_version": 1, + "findings": [ + { + "file": "S.cs", + "line": 2, + "code": "OWN001", + "component": "Skip.partial", + "event": "s", + "handler": "", + "message": "IDisposable local 's' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 12, + "code": "OWN001", + "component": "Skip.bare", + "event": "t", + "handler": "", + "message": "IDisposable local 't' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 21, + "code": "OWN001", + "component": "Skip.take", + "event": "x", + "handler": "", + "message": "IDisposable local 'x' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + }, + { + "file": "S.cs", + "line": 41, + "code": "OWN001", + "component": "Skip.arity", + "event": "u", + "handler": "", + "message": "IDisposable local 'u' is never disposed (leak)", + "kind": "disposable", + "advisory": false, + "severity": null, + "related": [], + "flow": [], + "ignore_reason": null, + "column": null + } + ] +} diff --git a/tests/fixtures/verdicts/vocab_unknown_op.verdicts.json b/tests/fixtures/verdicts/vocab_unknown_op.verdicts.json new file mode 100644 index 00000000..b9789b7f --- /dev/null +++ b/tests/fixtures/verdicts/vocab_unknown_op.verdicts.json @@ -0,0 +1,4 @@ +{ + "verdicts_version": 1, + "error": "unknown OwnIR flow op 'try' (F.cs:2) — extractor/core vocabulary skew; a new op must bump OWNIR_VERSION (see spec/OwnIR.md)" +} diff --git a/tests/test_verdict_fixtures.py b/tests/test_verdict_fixtures.py new file mode 100644 index 00000000..796d09ae --- /dev/null +++ b/tests/test_verdict_fixtures.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Layer 3 parity fixtures (P-022 #259) — the normalized verdict list. + +Each golden `tests/fixtures/verdicts/.verdicts.json` pins what the OwnIR +bridge CONCLUDES for one facts document: the complete `check_facts()` finding +list in the bridge's own order (`ownlang/verdicts.py`, every `Finding` member), +or the `OwnIRError` text when the bridge refuses the document. This is the +outer contract of spec/Bridge.md §6 (layer 3), the seam #259 checkpoints 4 +and 5 are measured at: the Rust `own-bridge` replays the same facts through +`own_bridge::check_facts` with zero Python and must agree — on identity, +anchor, kind and tiering at checkpoint 4; on messages and evidence too at +checkpoint 5. The golden always carries every member; a replay declares what +it compares. + +Three case sources, one golden tree: + +* **The swept facts corpora** — every `.facts.json` under + `tests/fixtures/ownir`, `tests/fixtures/lowered` and + `tests/fixtures/summaries` is swept automatically (no per-case listing to + forget), through the TOLERANT door (`json.load` + `check_facts`, the path + `test_ownir.py` and every embedder take — never `load()`, which would turn + `tolerant_unknown_kind` and the map-or-raise cases into load-time refusals + and hide the door the bridge actually guards). +* **Synthetic verdict cases** — `tests/fixtures/verdicts/.facts.json`, + listed exhaustively in the manifest's `cases` ledger (name + the BR rules + pinned), targeting verdict-mapping behavior the swept corpora do not reach: + EFF001 and DI001-005 through real `services[]`/`effects[]` blocks, the DI004 + call-site and DI005 store-site anchors, duplicate-site last-wins, multi-file + ordering, tiering and suppression, the OWN051 owned-local gate, the pooled + 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 + 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 + promotion. Python renders these like every other case — the ledger is a + statement about the port, never about the reference. + +* Python is authoritative: `python tests/test_verdict_fixtures.py --write` + regenerates every golden. Regeneration is deterministic, and verify mode + renders each case twice. +* The Rust side holds up its half in `rust/crates/own-bridge/tests/verdicts.rs`. + +Run: python tests/test_verdict_fixtures.py (verify) + python tests/test_verdict_fixtures.py --write (regenerate) + python tests/run_tests.py (runs it in the suite) +""" + +from __future__ import annotations + +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.verdicts import VERDICTS_VERSION, render_verdicts + +FIXDIR = os.path.join(os.path.dirname(__file__), "fixtures", "verdicts") +MANIFEST = os.path.join(FIXDIR, "manifest.json") +# The swept corpora, in a fixed order (a name collision across them is a +# ledger problem, not a silent shadowing). +CORPORA = ( + ("ownir", os.path.join(os.path.dirname(__file__), "fixtures", "ownir")), + ("lowered", os.path.join(os.path.dirname(__file__), "fixtures", "lowered")), + ("summaries", os.path.join(os.path.dirname(__file__), "fixtures", "summaries")), +) +_REFUSALS = ("bridge", "door") + + +def _manifest() -> tuple[list[str], dict[str, dict[str, object]], list[str]]: + """(synthetic case names sorted, rust_replay_excluded name->entry, problems).""" + problems: list[str] = [] + if not os.path.exists(MANIFEST): + return [], {}, [f"manifest missing: {MANIFEST}"] + with open(MANIFEST, encoding="utf-8") as f: + data = json.load(f) + if data.get("verdicts_version") != VERDICTS_VERSION: + problems.append( + f"manifest verdicts_version {data.get('verdicts_version')!r} != " + f"emitter VERDICTS_VERSION {VERDICTS_VERSION}") + excluded: dict[str, dict[str, object]] = {} + for e in data.get("rust_replay_excluded", []): + name, reason, refusal = e.get("name"), e.get("reason"), e.get("rust_refusal") + if not (isinstance(name, str) and name + and isinstance(reason, str) and reason): + problems.append(f"rust_replay_excluded entry needs a non-empty name " + f"and reason: {e!r}") + continue + if refusal not in _REFUSALS: + problems.append(f"rust_replay_excluded '{name}': rust_refusal must be " + f"one of {_REFUSALS}, got {refusal!r}") + contains = e.get("rust_error_contains") + if contains is not None and not (isinstance(contains, str) and contains): + problems.append(f"rust_replay_excluded '{name}': rust_error_contains " + f"must be a non-empty string when present") + if name in excluded: + problems.append(f"rust_replay_excluded lists '{name}' twice") + excluded[name] = e + names: list[str] = [] + for c in data.get("cases", []): + name = c.get("name") + if not isinstance(name, str) or not name: + problems.append(f"manifest case without a name: {c!r}") + continue + rules = c.get("rules") + if not (isinstance(rules, list) and rules + and all(isinstance(r, str) and r for r in rules)): + problems.append(f"manifest case '{name}': 'rules' must be a " + f"non-empty array of non-empty strings") + names.append(name) + if len(set(names)) != len(names): + problems.append("manifest contains duplicate case names") + return sorted(names), excluded, problems + + +def _disk_cases(directory: str, suffix: str) -> list[str]: + if not os.path.isdir(directory): + return [] + return sorted(n[:-len(suffix)] for n in os.listdir(directory) + if n.endswith(suffix)) + + +def _plan() -> tuple[dict[str, str], dict[str, dict[str, object]], list[str]]: + """The full case plan: name -> facts path, the exclusion ledger, and the + ledger problems. The corpora are swept automatically; the synthetic cases + must match the manifest exactly; names must be unique across all sources + (one golden tree serves them all); every exclusion must name a planned case.""" + synthetic, excluded, problems = _manifest() + plan: dict[str, str] = {} + origin: dict[str, str] = {} + for label, directory in CORPORA: + for name in _disk_cases(directory, ".facts.json"): + if name in plan: + problems.append(f"case name '{name}' exists in BOTH the " + f"{origin[name]} and {label} corpora — names must " + f"be unique across the swept corpora") + continue + plan[name] = os.path.join(directory, f"{name}.facts.json") + origin[name] = label + local = _disk_cases(FIXDIR, ".facts.json") + for missing in sorted(set(synthetic) - set(local)): + problems.append(f"manifest case '{missing}' has no facts file " + f"({missing}.facts.json) under fixtures/verdicts") + for unlisted in sorted(set(local) - set(synthetic)): + problems.append(f"'{unlisted}.facts.json' is not in manifest.json — " + f"add the case to the ledger (name, rules)") + for name in synthetic: + if name in plan: + problems.append(f"synthetic case '{name}' shadows a swept corpus " + f"case name ({origin[name]})") + else: + plan[name] = os.path.join(FIXDIR, f"{name}.facts.json") + for phantom in sorted(set(excluded) - set(plan)): + problems.append(f"rust_replay_excluded names '{phantom}', which is not a " + f"planned case") + return plan, excluded, problems + + +def _goldens() -> set[str]: + if not os.path.isdir(FIXDIR): + return set() + return {n[:-len(".verdicts.json")] for n in os.listdir(FIXDIR) + if n.endswith(".verdicts.json")} + + +def _project(facts_path: str) -> str: + with open(facts_path, encoding="utf-8") as f: + facts = json.load(f) + return render_verdicts(facts) + + +def run() -> int: + plan, excluded, fails = _plan() + if not plan and not fails: + fails.append("no cases planned (no facts under the corpora / fixtures/verdicts)") + n_refused = 0 + n_findings = 0 + for case, facts_path in sorted(plan.items()): + golden_path = os.path.join(FIXDIR, f"{case}.verdicts.json") + expected = _project(facts_path) + # determinism: the same facts must render byte-identically on re-run. + if _project(facts_path) != expected: + fails.append(f"{case}: projection is non-deterministic") + continue + if not os.path.exists(golden_path): + fails.append(f"{case}: golden missing; regenerate with " + f"'python tests/test_verdict_fixtures.py --write'") + continue + with open(golden_path, encoding="utf-8") as f: + actual = f.read() + if actual != expected: + fails.append(f"{case}: golden is stale (a verdict, an anchor or the " + f"projection changed); regenerate with " + f"'python tests/test_verdict_fixtures.py --write' and " + f"re-run the Rust side (cd rust && cargo test)") + continue + doc = json.loads(expected) + if doc.get("error") is not None: + n_refused += 1 + else: + n_findings += len(doc["findings"]) + for orphan in sorted(_goldens() - set(plan)): + fails.append(f"{orphan}: orphaned golden (not a planned case); remove " + f"it or restore the case (manifest/facts)") + if fails: + for f_ in fails: + print(f"FAIL: verdict fixture {f_}") + return 1 + print(f"verdicts (Layer 3) fixtures OK: {len(plan)} cases ({n_refused} refusals, " + f"{n_findings} findings; {len(excluded)} declared Rust exclusions) " + f"verified in sync") + return 0 + + +def write() -> int: + """Regenerate goldens for every planned case. Regeneration never accepts + a shrunken or inconsistent ledger (same rule as the Layer 2 family).""" + plan, _excluded, problems = _plan() + if problems: + for p in problems: + print(f"ERROR: {p}") + return 1 + for case, facts_path in sorted(plan.items()): + out = os.path.join(FIXDIR, f"{case}.verdicts.json") + with open(out, "w", encoding="utf-8") as f: + f.write(_project(facts_path)) + print(f"wrote {out}") + for orphan in sorted(_goldens() - set(plan)): + path = os.path.join(FIXDIR, f"{orphan}.verdicts.json") + os.remove(path) + print(f"removed orphaned {path}") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + raise SystemExit(write()) + raise SystemExit(run())