diff --git a/docs/notes/p022-diag-parity-checkpoint1.md b/docs/notes/p022-diag-parity-checkpoint1.md new file mode 100644 index 00000000..324c3716 --- /dev/null +++ b/docs/notes/p022-diag-parity-checkpoint1.md @@ -0,0 +1,223 @@ +# P-022 step 4 (#214) — diagnostics parity, checkpoint 1: frozen fixture + comparison design + +> Status: **checkpoint-1 deliverable** (frozen fixture and comparison design). +> The frozen Python reference is **provisionally approved** per owner direction: +> implementation may proceed (diagnostic types, generic solver, layered parity +> port), but **final semantic-port merge is gated on a separate post-batch OSS +> remeasure** run by another agent — see *Integration gate* below. Follows the +> #203 CFG-parity ratchet (`tests/test_cfg_fixtures.py` + +> `own-cfg/tests/parity.rs`), one layer up. + +## Precondition check (do not freeze an untrustworthy reference) + +#214's brief inherits the P-022 rule: freeze the Python reference only if the +latest full oracle remeasure on `main` is clean; if it shows **unexplained +disappearing findings**, do not freeze and do not start the semantic port. + +Trace of the evidence on current `main` (`4c5a86b`): + +- The latest documented remeasure, + [`precision-remeasure-2026-07-11.md`](precision-remeasure-2026-07-11.md) + (measured at `b1ee961`), recorded the regression class tracked as **#238**: + an empty source `XLWorkbook.Dispose()` that `Janitor.Fody` IL-weaves real + cleanup into at build time was read as a no-op and every `XLWorkbook` local + exempted (the note measured ~263 such over-exempted call sites in ClosedXML). + This was the **#238 regression class introduced by #233**; the disappearances + were **explained in full** in that note (not unexplained), alongside a 3-site + explicit-interface coverage gap. +- It was then **repaired by #240** (confining the empty-Dispose exemption to + enumerators — source emptiness is not a runtime no-op — plus soundness + follow-ups; commits `0c1c2f3`, `0d1411c`, `40756e3`, `607773f`). #238 names + the *issue that recorded the problem*, not an analyzer-version boundary. +- Crucially, those findings lived in the **C# Roslyn extractor** surface (the + OSS-repo sweep), **not** the `ownlang` core `.own`/OwnIR path — which is the + exact surface this fixture freezes. The Python reference suite + (`python tests/run_tests.py`) is **100% green** on `4c5a86b` + (132/132 analysis, 3000-iter codegen fuzzer clean, CFG/syntax parity in sync). + +**Live verification of the fix (added after the initial trace).** The `.NET` +SDK installs from the web via the official `dotnet-install.sh` (8.0.422), the +Roslyn extractor builds clean, and the real `own-check` pipeline (fresh +extractor + Python core) was run on the #238/#240 regression pins on `4c5a86b`: + +- `frontend/roslyn/samples/weaved/WeavedEmptyDispose.cs` (a *perfect* + enumerator-shaped empty `Dispose` sitting next to a `FodyWeavers.xml`) is + correctly **flagged** OWN001 — the finding does **not** disappear, which is + exactly the Janitor.Fody over-exemption of the #238 regression class + (introduced by #233, repaired by #240). +- `frontend/roslyn/samples/EmptyDisposeSample.cs`: all **8/8 "FLAGGED (control)"** + shapes fire (weaver-adjacent non-enumerator, empty override over a real base, + empty sync `Dispose` with a real `DisposeAsync`, base-chain bare + `DisposeAsync`, non-generic `IEnumerator`, …). The two legitimate + `IEnumerator` empty-`Dispose` exemption controls remain silent in the + regression fixture; broader false-positive behavior is left to the full OSS + remeasure. + +So the one documented class of disappearing findings is **positively confirmed +resolved on current `main`** through the actual pipeline, not merely inferred +from commit messages. The literal gate does not trip and the `.own` reference +frozen here is independently green. + +**Real-repo confirmation on ClosedXML itself.** Beyond the isolated pins, the +actual sweep target was cloned and re-measured on current `main` +(`4e89dced`, the exact commit from the remeasure note): `ClosedXML/ClosedXML.csproj` ++ `ClosedXML.Examples/ClosedXML.Examples.csproj` built (net8.0), then +`scripts/own-check.sh --flow-locals -- ClosedXML.Examples.csproj`. The +`XLWorkbook.Dispose()` at `ClosedXML/Excel/XLWorkbook.cs:874` is still empty in +source ("Leave this empty so that Janitor.Fody can do its work") with +`ClosedXML/FodyWeavers.xml` present — the exact weaved shape. Result: **119 +OWN001 findings, every one an undisposed `XLWorkbook` `wb`/`workbook` local**. +Those are the locals the #238 regression class (introduced by #233) had silenced +(the repo collapsed to ~5); on current `main` they are **flagged again** — the +disappearing findings have *returned* on the real repository. (119 vs the note's +"~263 call sites" is a scope difference — Examples project only, net8.0 TFM, +path-sensitive `--flow-locals` — not a discrepancy in the conclusion: the +over-exemption is gone.) + +## Guarantee split — what this discharge proves, and what it does not + +Per owner direction, the point-check is accepted as sufficient discharge of the +specific #238/#240 regression precondition, and the full five-repository sweep is +**not** re-run here (it is a separate Phase 2 assigned to another agent). Do not +overclaim what the point-check covers. + +**Proven** (live extractor → OwnIR → Python-core run on `4c5a86b`): + +- the Fody-enabled empty-`Dispose` enumerator fixture stays OWN001; +- all eight #238/#240 soundness controls stay flagged; +- the two allowed `IEnumerator` empty-`Dispose` cases in those fixtures stay + silent; +- the specific regression recorded in #238 and repaired by #240 does not + reproduce (confirmed on the real ClosedXML source too); +- the current `.own` reference suite is green. + +**Not proven** (left to the separate full OSS remeasure): + +- absence of *other* disappearing findings across the five OSS repositories; +- absence of new false positives outside the checked fixtures; +- global precision of the whole most-recent precision batch; +- apples-to-apples parity against the old OSS baseline. + +## Integration gate (final semantic-port merge) + +**Final merge is gated on the separate post-batch OSS remeasure; checkpoint 1 +permits implementation to proceed but does not substitute for the broad oracle +audit.** Before the final semantic-port merge, one of two must happen: + +1. **Full sweep green.** The other agent provides: identical pinned repositories; + identical commands / reference setup; the added/removed findings; an + explanation of *every* removed finding; and preservation of the confirmed true + positives. The provisional reference then becomes final. +2. **Full sweep finds an unexplained disappearance.** Then: do **not** fix it in + Rust only; do **not** regenerate the fixture to a newly convenient output; + **stop** the final merge; fix it **Python-first**; regenerate the golden + fixture explicitly; and re-bring Rust to parity. A sweep that finishes *after* + the solver is already built is fine — it is the final integration gate, not a + reason to freeze the whole port. + +The freeze itself is cheap to revert: one regenerable command +(`python tests/test_diag_fixtures.py --write`) over the green core. + +## The comparison surface + +The frozen contract is `tests/fixtures/diag_parity.json`: for every `.own` +input, the **ordered list of `[line, code]` pairs** the `check` surface emits. +The oracle key is `(path, line, code)` — `path` = the case's input identity, and +each case's list is compared **in emission order**, so list position pins the +deterministic intra-location (same-line, same-code) ordering #214 asks for. No +sorting, no dedup on top of Python's own output (the parity policy forbids +sorting away meaningful duplicates). + +- **Authoritative side (Python):** `tests/test_diag_fixtures.py`. It runs the + *real* `check` surface — exactly `ownlang.__main__._collect` — over the same + corpus the CFG ratchet sweeps (`corpus/`, `examples/`, `tests/fixtures/`) plus + seven curated single-file cases whose outcomes are **computed, never + hand-written**. Regenerate with `python tests/test_diag_fixtures.py --write`; + the in-suite `run()` (auto-discovered by `tests/run_tests.py`) fails if the + committed file is stale. Steady state runs zero Rust; the Rust side runs zero + Python. +- **Replay side (Rust):** `rust/crates/own-diagnostics` provides the verdict + data types and the `DiagKey`/case-loading harness; `tests/fixture.rs` locks + the comparison *plumbing* now (the fixture parses into `(name, source, + expected: Vec)`, every frozen code is a known `TITLES` entry, + emission order is preserved). The actual replay — parse → lower → analyse → + compare `produced == expected` — is a `TODO(#214, checkpoint 2)` in that file, + waiting on `own-analysis`. + +Compared on **code + line only**. Message text, evidence slices and SARIF are +later steps (5) and are deliberately not frozen here; the `own-diagnostics` +types carry the fields (message, subject, resource_kind, evidence) so those +seams are a pure addition, not a reshape. + +## What the `.own` surface exercises — and what it cannot (scoping) + +`check_module` (the `.own` pipeline) runs **buffer-policy validation + +`check_lifetimes` + per-function `analyze`**. So this fixture pins the +**ownership**, **lifetime/region**, and **buffer-policy** diagnostic families +(OWN001–OWN052 as they arise from source). + +The **effects (EFF\*)** and **DI (DI\*)** families are **sidecar analyses the +OwnIR bridge routes facts to** (`ownlang/ownir.py::check_facts` calls +`ownlang/effects.py` and `ownlang/di.py`); `check_module` never invokes them, so +**no `.own` input exercises them**. #214 still asks to port all four analyses to +`own-analysis`, and that stands — but their end-to-end *diagnostic* parity needs +OwnIR **fact** fixtures flowing through `own-bridge` (migration step 6), which is +out of #214's `.own`-corpus fixture scope. Concretely, at checkpoints 2–3: + +- ownership + lifetime parity is proven by this `.own` fixture (the four-analysis + order in #214 still holds: ownership first, then lifetime); +- the ported effects/DI analyses are validated at the analysis level by + curated unit tests (lattice laws, transfer functions), with full + fixture-backed diagnostic parity deferred to the `own-bridge` step that feeds + them facts. + +This is surfaced explicitly rather than papered over, per the parity policy +("never weaken compared fields / map multiple Rust diagnostics to one code"). + +## Preserved Python quirk — the designated future issue + +`_collect` maps a **lex/parse failure to a single synthetic `OWN020`** at the +error line: + +```python +except (ParseError, LexError) as e: + line = getattr(e, "line", 0) + return [Diagnostic("OWN020", str(e).split(": ", 1)[-1], line)], None +``` + +`OWN020`'s title is *"unsupported construct (out of scope for the MVP)"* — so a +plain **syntax error** is reported under the "unsupported construct" code, not a +dedicated parse-error code. This is mildly suspicious (a malformed token is not +an "unsupported construct"), but it is the reference behaviour and the Rust +`check` surface **must match it** (emit `OWN020` at the error line). The curated +case `curated_parse_error_is_own020` pins it. + +Per #214's guardrail ("if a Python behaviour looks like a bug mid-port, do NOT +fix it in Rust; note it and keep parity — a deliberate divergence is a separate, +Python-first change"), this is **matched, not fixed here**, and recorded as the +designated **future issue**: *"`check` reports syntax/lex errors under OWN020 +('unsupported construct') rather than a dedicated parse-error diagnostic code."* +Any change is Python-first and independently reviewed; it must not be smuggled in +under the parity port. + +## Deliverables in this checkpoint + +| Artifact | Path | +| --- | --- | +| Python fixture generator (authoritative) | `tests/test_diag_fixtures.py` | +| Frozen golden set (69 cases, 66 `(line,code)` pairs) | `tests/fixtures/diag_parity.json` | +| Verdict data types (data-only port of `diagnostics.py`) | `rust/crates/own-diagnostics/src/diagnostic.rs` | +| Comparison harness plumbing + replay TODO | `rust/crates/own-diagnostics/tests/fixture.rs` | +| Architecture DAG edge test (`cargo metadata`) | `rust/crates/own-diagnostics/tests/dag.rs` | +| Suite wiring | auto-discovered by `tests/run_tests.py` | + +Regenerate the fixture: **`python tests/test_diag_fixtures.py --write`** (the one +documented explicit command; a stale file fails the suite). + +## Explicitly NOT done here (checkpoint boundary) + +The generic worklist solver, the `Lattice`/`Analysis` traits, and any domain +analysis (ownership/lifetime/effect/DI) — those are checkpoint 2 (generic solver ++ ownership parity) and checkpoint 3 (full-analysis parity), each with its own +review. `own-analysis` is not yet a workspace member. Evidence/SARIF parity +(step 5) and the effects/DI fact-fixture surface (step 6) are later still. diff --git a/docs/notes/p022-full-parity-checkpoint3.md b/docs/notes/p022-full-parity-checkpoint3.md new file mode 100644 index 00000000..3202cbef --- /dev/null +++ b/docs/notes/p022-full-parity-checkpoint3.md @@ -0,0 +1,111 @@ +# P-022 step 4 (#214) — checkpoint 3: the remaining analyses + +> Status: **checkpoint-3 work** (the remaining #214 analyses as independent +> `own-analysis` implementations, in the reviewer's order: lifetime → buffer +> policy → effect → DI). Builds on the approved checkpoint 2 (generic solver + +> ownership). Evidence-text and SARIF stay out of #214 (later steps). The OSS +> integration gate is satisfied by merged **PR #243**. + +## 3a — lifetime + buffer policy: full `.own` corpus parity + +- **`own-analysis::lifetime`** — an exact port of `ownlang/lifetimes.py`: an + AST-level region analysis (not CFG/solver). Structural validation of the + `lifetime` order (OWN030 undefined, OWN031 redeclared, OWN036 cyclic) + the + per-function **region-escape** check (OWN014: a `subscribe self to source` + where `source` strictly outlives the captured object promotes it and it + leaks). Ports `_iter_subscribes`, `_strictly_longer` (transitive closure), + `check_lifetimes`, `_check_fn`. +- **Buffer policy** — `own_cfg::validate_policies` (OWN018/019/021/023/024) was + already ported at step 3; checkpoint 3 **wires it into the check surface** + rather than re-implementing the algorithm. +- **`own-analysis::check_module`** — the full `.own` check surface, an exact port + of `ownlang.__main__.check_module`: buffer policy → lifetime → per-function + resolver (`d1`) then ownership (`d2`), stable-sorted by `(line, code)`. The + pre-sort order matches Python's for tie-breaking (a source line belongs to one + function, so same-`(line, code)` ties are within-function where `d1` precedes + `d2` under both orders; the module-level passes come first under both). + +### Architecture — no new production edge + +The lifetime analysis reads the AST (`lifetime` decls + `subscribe` statements, +which the CFG does not model). Per the checkpoint-2 Blocking-3 resolution, it +consumes the AST **through `own_cfg::ast`** (re-exported by `own-cfg`, the same +pattern as `own_cfg::Effect`) — so `own-analysis` keeps **no production +`own-syntax` edge**. The DAG edge test and +`own_analysis_has_no_production_parser_edge` stay green. + +### Parity result + +The full frozen corpus now passes end-to-end: + +- **`tests/parity.rs`: 69/69 cases asserted, 0 skipped** — the 3 previously + deferred cases (`corpus/wpf/systemevents-region-escape`, + `corpus/wpf/viewmodel-escapes-to-app`, `curated_buffer_policy`) now match + exactly through `check_module`. +- **`tests/diff_gen.rs`: 160/160** seeded generated programs, exact. +- Focused `lifetime.rs` unit tests: OWN014 escape, equal-lifetime clean, OWN036 + cyclic, OWN030/OWN031 undefined/redeclared, subscribe-nested-in-branch. + +Both Python fixtures are unchanged (no fixture or acceptance change). `.own` +diagnostic parity for #214 is now **complete**. + +## 3b — effect + DI (ported) + +`effects.py` (EFF001 render-time IO storm) and `di.py` (DI001–005 captive +dependency) are **OwnIR-fact sidecar families**: the bridge feeds them facts; +they are never invoked by the `.own` `check_module`. Ported as independent +`own-analysis` modules with unit tests; end-to-end diagnostic parity needs the +fact surface and lands with `own-bridge` (step 6). + +- **`own-analysis::effect`** — exact port of `effects.py`: the stability lattice + (`Stable < Unknown < Unstable`) resolved to a fixpoint over the render-scope + binding graph with memoization + a cycle guard; `find_effect_storms` flags an + IO effect with a provably `Unstable` dependency (EFF001). 7 unit tests: fresh + object → storm, memoised → clean, no-IO → silent, derivation-chain propagation, + opaque-call → Unknown, plain-identifier stable, identity cycle safe. +- **`own-analysis::di`** — exact port of `di.py`: the `Service` registration + graph + all five DFS analyses (DI001 captive scoped, DI002 weak capture, DI003 + captured transient `IDisposable`, DI004 root service-location, DI005 + scope-cached captive), each with the transient-follow / singleton-stop / cycle + guards. 8 unit tests, one per code plus the "all-singleton is clean" and + "inner-singleton reported on its own pass" controls. + +Both are **data-only** on the control-flow-relevant facts; presentation metadata +(ctor/site tuples for evidence text) is omitted (evidence/SARIF is a later step, +out of #214). The parity/differential tests flag any `.own` case that grows a +DI/EFF/OBL code (the corpus has none) so the boundary can't drift silently. + +## 3b review round — verdict path/line anchors + fact-level differential + +The checkpoint-2 `(path, line, code)` contract applies to effect/DI too: the +**file** and the **primary line** are verdict identity, not presentation. + +- **Anchors matched to the bridge** (`ownir.py`): DI001/002/003 → the singleton + registration site; **DI004** → the root-resolution **call site** of the entry + type (`_di004_primary`, registration fallback); **DI005** → the field-store + **cache site** of the cached entry (`_di005_primary`, registration fallback); + EFF001 → the effect's own `(file, line)`. `Service` now carries `file`, + `root_resolve_sites`, `scope_cache_sites`; `Effect`/`EffectStorm` carry `file`. + `di_verdicts` / `effect_verdicts` return the exact primary `(path, line, code)`; + DI is combined in the bridge append order then sorted `(file, line, code)`, + effect sorted `(file, line, dep)`. The analysis selects the anchor — the bridge + will only map, not repair. +- **Python-authored fact-level differential** — `tests/test_di_eff_fact_parity.py` + freezes normalized effect/DI **fact inputs** and the expected `(path, line, + code)` computed by the real `ownlang.effects`/`ownlang.di` finders + the bridge + anchor rules into `tests/fixtures/di_eff_fact_parity.json`. + `own-analysis/tests/fact_parity.rs` replays with **zero Python**. **8 effect + cases / 13 DI cases** covering: direct + transitive DI001–005, DI004 transitive + disposable with the **entry call-site** anchor, DI005 transitive scoped with + the **entry cache-site** anchor, duplicate/reported suppression, cycles, + unknown lifetimes, multi-file ordering (DI + effect equal-line-different-file). + All exact. + +## Status + +All four #214 analyses are now ported as independent `own-analysis` +implementations (ownership, lifetime, effect, DI) plus buffer-policy wiring. The +`.own` diagnostic parity is complete (69/69 + 160/160 generated); effect/DI +diagnostic parity is unit-pinned and completes end-to-end with `own-bridge` +(step 6). Evidence-text and SARIF (step 5) remain out of #214. Final merge stays +gated on merged **PR #243**. diff --git a/docs/notes/p022-ownership-parity-checkpoint2.md b/docs/notes/p022-ownership-parity-checkpoint2.md new file mode 100644 index 00000000..2644f2c9 --- /dev/null +++ b/docs/notes/p022-ownership-parity-checkpoint2.md @@ -0,0 +1,163 @@ +# P-022 step 4 (#214) — checkpoint 2: generic solver + ownership parity + +> Status: **checkpoint-2 deliverable** (generic worklist solver + the ownership +> analysis, gated by the frozen diagnostics oracle). Second of #214's three +> review checkpoints. Builds on +> [checkpoint 1](p022-diag-parity-checkpoint1.md) (the frozen +> `tests/fixtures/diag_parity.json` + comparison design). Lifetime/effect/DI and +> evidence/SARIF are checkpoint 3 / later steps. + +## What landed + +- **`own-analysis::solver`** — a generic monotone-lattice forward worklist + solver (the `rustc_mir_dataflow` shape the Python `_Analyzer.fixpoint` ports + to): `Lattice` / `ControlFlowGraph` / `Analysis` traits, RPO scheduling, a + schedule-independent fixpoint, and a convergence guard that fails loudly on a + non-monotone join. Dependency-free. +- **`own-analysis::ownership`** — an exact port of `ownlang/analysis.py`: the + `{OWNED,MOVED,RELEASED,ESCAPED}` bit-lattice `State` (per-RID `u8` bitset, + loans, handle→RID aliasing, move/acquire provenance), `join` with the Python + invariants (block-scoped loans equal at a merge; 1:1 handle→RID), the full + `step()` transfer over every `Instr`, the two-phase `run` (silent fixpoint, + then emitting pass + end-of-function leak checks). RID = `SymId` (`u32`), the + deterministic replacement for Python's `id(sym)`. + +## Parity result + +`tests/parity.rs` replays the frozen fixture through the ported `check` surface +(own-syntax parse → own-cfg resolver `d1` → ownership `d2`, stable-sorted by +`(line, code)`; a parse error is the preserved OWN020 quirk): + +- **66 covered cases asserted, all pass exactly** (full ordered `(line, code)` + list — no field weakened); +- **3 deferred** to checkpoint 3, listed by name: two OWN014 escape cases + (`corpus/wpf/systemevents-region-escape`, `corpus/wpf/viewmodel-escapes-to-app`) + and one OWN019 buffer-policy case (`curated_buffer_policy`). + +### The partition is sound, not a weakening + +A case is **covered** iff none of its Python codes belong to a not-yet-ported +pass. Ported at checkpoint 2: the ownership analysis (`analyze`, codes +OWN001–OWN013, OWN015–OWN017, OWN025, OWN034, OWN041) and — already parity-frozen +by #203 — the `own-cfg` resolver (`d1`). **Not** ported: `check_lifetimes` +(OWN014/OWN036) and `validate_policies` (OWN019/OWN021/OWN023/OWN024), plus the +resolver-name codes those passes can *also* emit (OWN030/OWN031) and the +OwnIR-fact sidecar families (DI\*/EFF\*/OBL\*, own-bridge step 6). For a covered +case those unported passes are provably silent, so Python's full output equals +`d1 + d2` — the exact quantity Rust computes. Covered cases are therefore +asserted on the **complete** ordered `(line, code)` list; deferred cases are +skipped whole (never half-asserted). + +## Mandated correctness battery + +- **Lattice laws** (`ownership.rs` unit tests): idempotent / commutative / + associative `join` and `x <= join(x, y)`, **exhaustive** over the finite + per-RID state space (all 16 masks × triples); plus `bottom` identity, the + growth-reporting contract, and the two different default rules (absent RID + **reads** as OWNED, **joins** as ∅ — the Python `.get(rid, {OWNED})` vs + `.get(k, set())` split, pinned by a test). +- **Solver laws** (`solver.rs`, checkpoint 2a): CFG shapes (straight/diamond/ + self-loop/nested-loop/unreachable/multi-pred/abnormal-exit), exhaustive + lattice laws on the test lattice, worklist-order independence across five + schedules, and the convergence guard firing on a diverging join. +- **Metamorphic** (`metamorphic.rs`): renaming a local preserves `(line, code)`; + repeated analysis is identical (determinism); adding unreachable code never + removes a finding; diagnostics survive a serde round-trip. +- **Differential (generated)**: `tests/test_diff_gen_fixtures.py` deterministically + generates **160 seeded mini-programs** from the ownership primitives + (acquire/release/use/move/call(borrow|consume)/branch/loop/return), computes + the Python `(line, code)` goldens, and freezes `source + seed + diags` into + `tests/fixtures/diag_diff_gen.json`. `own-analysis/tests/diff_gen.rs` replays + them with **zero Python** and, on any divergence, prints the failing **seed + + source** so it becomes a permanent regression. 160/160 covered, exact match. + (The frozen 69-case corpus fixture is a second, curated differential.) + +## Review round 2 — the four checkpoint-2 blocking fixes + +- **B1 — `State` now satisfies the `Lattice` contract.** The solver seeds a merge + from the **first predecessor with an out-fact** (clone) and joins the rest — + matching Python's `in_state_of`, so `join` is only ever called between two real + predecessor states, never `bottom.join(loaded)`. `State::join` also handles the + ⊥ identity explicitly (`⊥ ∨ x = x` with active loans) and asserts **full + loan-map equality** (owner + kind per binding), not just key equality — a + same-binding/different-owner-or-kind merge now fails loudly. The Python `join` + was tightened to full-map equality **first** (a no-op: 132/132 green, fixture + byte-identical) so the invariant is identical on both sides. New tests: + bottom-identity-with-loans, compatible-loan commutativity/associativity, + same-key/different-value fail-loud, and a real CFG with a `borrow_mut` spanning + a branch merge (a loan live on both merge edges). +- **B2 — the schedules are now materially distinct.** True FIFO (`VecDeque` + front/back) and true LIFO (`Vec` stack), not block-id-keyed aliases. A + visit-order recorder proves ≥3 distinct visitation orders across the five + schedules while all agree on the fixpoint; plus successor-order-permutation and + block-ID-permutation invariance, and an ownership-specific (real `State` + lattice) schedule-independence test over a loop+branch program. +- **B3 — no production parser edge.** `own-analysis` reads the effect type through + `own_cfg::Effect` (re-exported by `own-cfg`); `own-syntax` moved to + dev-dependencies (parity/metamorphic/diff-gen tests). The DAG edge test now + filters to production (`kind: null`) deps and a dedicated assertion + (`own_analysis_has_no_production_parser_edge`) prevents the edge returning. +- **B4 — generated differential battery** (above). + +The 66/66 covered parity result is unchanged after all four fixes. + +## Review round 3 — explicit lattice bottom (the one remaining blocker) + +Round 2's `State::is_bottom()` inferred ⊥ from *structural emptiness* (all maps +empty), which conflated the lattice bottom with a **real reachable predecessor +that happens to carry an empty state**. That let `join(Concrete(empty), +Concrete(with_loan))` short-circuit as an identity and bypass the loan +invariant — order-independently. + +Fixed by making bottom an **explicit variant**, never inferred: + +```rust +enum StateFact { Bottom, Reachable(State) } +``` + +- `StateFact` is the solver's `Lattice::Fact`; `Bottom` is the identity, only + ever the seed for a block none of whose predecessors are evaluated yet. +- `State::join_data` (concrete ∨ concrete) enforces the full loan invariant + **unconditionally** — no bottom special-casing lives there. +- `join`: `⊥ ∨ x = x`, `x ∨ ⊥ = x`; `Concrete(empty) ∨ Concrete(with_loan)` (and + the symmetric direction) **fail loudly**; `Concrete(empty) ≠ Bottom`. +- `transfer(Bottom) = Bottom` (no info in → no info out; a transient that is + overwritten once a real predecessor arrives — a reachable block's converged + in-fact is always `Reachable`). + +New tests: `bottom_join_loan_state_is_identity`, +`concrete_empty_join_loan_state_fails_loud`, +`loan_state_join_concrete_empty_fails_loud`, +`concrete_empty_is_not_lattice_bottom`. Verified unchanged after the fix: +66/66 curated parity, 160/160 generated differential, schedule/permutation +tests, and both Python fixtures byte-identical. + +## Deliberately preserved Python behaviors (matched, not "fixed") + +- **OWN020 for syntax errors** (from checkpoint 1) — still matched. +- **Two OWN003 shapes merged**: Python emits a distinct *message* for "released + twice" vs "may already be released on some path", but both are code **OWN003** + at the same line; the Rust port emits OWN003 for either (only `(line, code)` + is compared at this step). Same for the "consumed" vs "released" OWN002 split. + This is a message-text distinction, not a verdict distinction — it returns + when message-text parity is added (step 5). No verdict is changed. + +## Integration gate + +The separate full OSS remeasure now exists as **PR #243**; it is not re-run here. +**Final #214 merge remains gated on PR #243 being independently reviewed and +accepted/merged.** If that sweep surfaces an unexplained disappearance, it is +fixed **Python-first**, the golden fixture is regenerated explicitly, and Rust is +re-brought to parity — never patched in Rust alone. + +## Checkpoint 3 scope (clarified per review) + +Checkpoint 3 ports **all remaining #214 analyses as independent `own-analysis` +implementations**: **lifetime** (OWN014/OWN036 escape/region, `check_lifetimes`), +**buffer policy** (OWN019/OWN021/OWN023/OWN024, `validate_policies`), **effect** +(EFF\*), and **DI** (DI\*). `own-bridge` later *wires OwnIR facts into* the +effect/DI analyses but does **not** own their algorithms — the algorithms live in +`own-analysis`. The lifetime + buffer-policy ports close the 3 deferred cases and +bring the `.own` corpus to full parity. **Evidence-text and SARIF parity remain +later migration steps (step 5) and must not be pulled into #214** to make a +checkpoint look more complete. diff --git a/ownlang/analysis.py b/ownlang/analysis.py index 9f626ae8..17c6f78e 100644 --- a/ownlang/analysis.py +++ b/ownlang/analysis.py @@ -200,7 +200,13 @@ def join(a: State, b: State) -> State: # An explicit raise, not `assert`: `python -O` strips asserts, and a silently # mismatched loan set at a merge would defeat the invariant this locks (same as # `_join_handle_rid` above). Keep it loud in every build. - if set(a.loans) != set(b.loans): + # Full-map equality (owner + kind per binding), not just the key set: two + # merging paths that somehow carried the *same* binding with a different + # owner/kind must fail loudly, not silently pick a side. Block-scoped borrows + # make this a `{} == {}` check in practice (loans close within their block), + # so this tightening changes no output; it only closes an impossible-but- + # unguarded hole (mirrored in the Rust `State::join`). + if a.loans != b.loans: raise AssertionError( "active loans differ at a control-flow merge; this should be impossible " "for block-scoped borrows (they close within the scope that opened them)" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 42aaaae7..5613e68d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -14,6 +14,16 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "own-analysis" +version = "0.1.0" +dependencies = [ + "own-cfg", + "own-diagnostics", + "own-syntax", + "serde_json", +] + [[package]] name = "own-cfg" version = "0.1.0" @@ -22,6 +32,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "own-diagnostics" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "own-ir" version = "0.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index fb6d5ca7..223a934f 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,7 +8,7 @@ [workspace] resolver = "2" -members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg"] +members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis"] [workspace.package] edition = "2021" diff --git a/rust/crates/own-analysis/Cargo.toml b/rust/crates/own-analysis/Cargo.toml new file mode 100644 index 00000000..276494c0 --- /dev/null +++ b/rust/crates/own-analysis/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "own-analysis" +description = "The analysis heart (P-022 step 4, #214): a generic monotone-lattice worklist solver plus the ownership/lifetime/effect/DI analyses, ported from ownlang and gated by the diagnostics differential oracle" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +version = "0.1.0" + +# Per P-022 the solver CONSTRUCTS own-diagnostics verdicts (own-analysis -> +# own-diagnostics) and consumes the own-cfg lowering (which re-exports own-syntax +# / own-ir). It must never be depended on by own-diagnostics/own-cfg/own-ir. The +# generic `solver` module lands first and is dependency-free; the domain analyses +# (ownership next) add own-cfg / own-diagnostics. The DAG edge test +# (own-diagnostics/tests/dag.rs) already allows that (reviewed) set. +[dependencies] +own-cfg = { path = "../own-cfg" } +own-diagnostics = { path = "../own-diagnostics" } + +[dev-dependencies] +# own-syntax is a TEST-only edge (parity/metamorphic tests call `parse`); the +# production analysis reads the effect type through `own_cfg::Effect`, never the +# parser — the DAG edge test asserts no production own-analysis -> own-syntax edge. +own-syntax = { path = "../own-syntax" } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/rust/crates/own-analysis/src/check.rs b/rust/crates/own-analysis/src/check.rs new file mode 100644 index 00000000..2e08bd39 --- /dev/null +++ b/rust/crates/own-analysis/src/check.rs @@ -0,0 +1,61 @@ +//! The full `.own` **check surface** — the port of `ownlang.__main__.check_module`. +//! +//! Composes every `.own` diagnostic pass in Python's exact order, then sorts by +//! `(line, code)` (a stable sort, so ties keep emission order): +//! +//! 1. buffer-policy validation (`own_cfg::validate_policies`); +//! 2. lifetime/region ([`crate::lifetime::check_lifetimes`]); +//! 3. per-function resolver diagnostics (`own_cfg::build_module`, "d1") then the +//! ownership analysis ([`crate::ownership::analyze`], "d2"). +//! +//! `own_cfg::build_module` returns all functions' `d1` concatenated, and this +//! runs `analyze` per function, so the pre-sort order is `[d1…][d2…]` rather than +//! Python's per-function `[fn.d1, fn.d2]` interleave. That difference is +//! invisible after the stable sort: a source line belongs to exactly one +//! function, so a same-`(line, code)` tie can only occur within one function, +//! where `d1` precedes `d2` under **both** orders. The module-level passes (1, 2) +//! come first under both. + +use own_cfg::ast::Module; +use own_cfg::{build_module, collect_policies, validate_policies, Diag}; +use own_diagnostics::{title, Diagnostic}; + +use crate::{lifetime, ownership}; + +/// Convert an `own-cfg` resolver/policy diagnostic (code + line) into an +/// `own-diagnostics` value; the message is the title (text parity is a later +/// step). `code` is a compile-time constant, so construction cannot fail. +fn push_cfg_diag(out: &mut Vec, d: &Diag) { + let msg = title(d.code).unwrap_or(d.code); + match Diagnostic::new(d.code, msg, d.line) { + Ok(x) => out.push(x), + Err(_) => debug_assert!(false, "own-cfg emitted an unknown code {}", d.code), + } +} + +/// Run the whole `.own` check surface over a parsed module. +/// +/// Returns the diagnostics sorted by `(line, code)` — byte-for-byte the set +/// `python -m ownlang check` produces (compared on `(line, code)` at this step). +#[must_use] +pub fn check_module(module: &Module) -> Vec { + let mut diags: Vec = Vec::new(); + + // 1. buffer-policy validation (OWN018/019/021/023/024/…). + for d in &validate_policies(&collect_policies(module)) { + push_cfg_diag(&mut diags, d); + } + // 2. lifetime/region (OWN014/OWN030/OWN031/OWN036). + diags.extend(lifetime::check_lifetimes(module)); + // 3. per-function resolver diagnostics (d1) then ownership (d2). + let (cfgs, d1) = build_module(module); + for d in &d1 { + push_cfg_diag(&mut diags, d); + } + for cfg in &cfgs { + diags.extend(ownership::analyze(cfg)); + } + + diags.sort_by(|a, b| a.line.cmp(&b.line).then_with(|| a.code.cmp(&b.code))); + diags +} diff --git a/rust/crates/own-analysis/src/di.rs b/rust/crates/own-analysis/src/di.rs new file mode 100644 index 00000000..21ed5f8c --- /dev/null +++ b/rust/crates/own-analysis/src/di.rs @@ -0,0 +1,543 @@ +//! DI lifetime analysis (DI001–DI005, captive dependency) — an exact port of +//! `ownlang/di.py`. +//! +//! A deterministic property of the DI **registration graph** (who is registered +//! with which lifetime, and who they depend on), not the acquire/release model: +//! +//! * **DI001** singleton → scoped (directly or via transients): captive; +//! * **DI002** singleton weakly captures a scoped service (`WeakReference`); +//! * **DI003** singleton captures a transient `IDisposable`; +//! * **DI004** singleton hand-resolves a transient `IDisposable` from its root +//! provider (`GetService`/`GetRequiredService` — service-locator); +//! * **DI005** singleton caches a scope-resolved scoped service into a field. +//! +//! **Fact-driven**: the `OwnIR` bridge (own-bridge, step 6) feeds [`Service`] +//! facts; there is no `.own` surface. This module ports the algorithms and pins +//! them with unit tests; end-to-end diagnostic parity lands with the bridge. +//! `(line, code)` is the parity contract (DI findings anchor at the registration +//! line here; the bridge does final call-site anchoring/evidence at step 6). + +use std::collections::{BTreeMap, BTreeSet}; + +use own_diagnostics::{title, Diagnostic}; + +/// A service lifetime. Unknown strings parse to `None` (ignored, like Python's +/// membership checks against the three known lifetimes). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lifetime { + Singleton, + Scoped, + Transient, +} + +impl Lifetime { + /// Parse a registration lifetime; unknown → `None`. + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "singleton" => Some(Self::Singleton), + "scoped" => Some(Self::Scoped), + "transient" => Some(Self::Transient), + _ => None, + } + } +} + +/// One DI registration. +/// +/// Mirrors the control-flow-relevant fields of `di.Service`; presentation-only +/// metadata (ctor/site tuples, used for evidence text) is omitted — evidence and +/// SARIF are a later step, out of #214. +/// A `(type, file, line)` call/store site (a `root_resolve` or `scope_cache` +/// site), the DI004/DI005 primary anchor. +pub type SiteTriple = (String, String, u32); + +#[derive(Debug, Clone)] +pub struct Service { + pub name: String, + pub lifetime: Option, + pub deps: Vec, + pub disposable: bool, + /// The **registration** site — the DI001/002/003 primary `(path, line)` and + /// the DI004/DI005 fallback when the call/store site is unknown. + pub file: String, + pub line: u32, + /// Services injected via `WeakReference` (DI002). + pub weak_deps: Vec, + /// Types hand-resolved from an injected root `IServiceProvider` (DI004). + pub root_resolves: Vec, + /// `(type, file, line)` of each `root_resolves` call site — the DI004 primary. + pub root_resolve_sites: Vec, + /// Types resolved from a self-created scope and cached into a field (DI005). + pub scope_cached: Vec, + /// `(type, file, line)` of each `scope_cached` field store — the DI005 primary. + pub scope_cache_sites: Vec, +} + +impl Service { + /// A minimal service with a name + lifetime + registration `(file, line)`. + #[must_use] + pub fn new(name: &str, lifetime: Lifetime, file: &str, line: u32) -> Self { + Self { + name: name.to_owned(), + lifetime: Some(lifetime), + deps: Vec::new(), + disposable: false, + file: file.to_owned(), + line, + weak_deps: Vec::new(), + root_resolves: Vec::new(), + root_resolve_sites: Vec::new(), + scope_cached: Vec::new(), + scope_cache_sites: Vec::new(), + } + } +} + +/// A DI finding, carrying the **primary** verdict anchor `(file, line, code)`. +/// +/// The registration site for DI001/002/003, the resolve call site for DI004, the +/// field-store site for DI005 (each with the registration fallback Python uses). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiFinding { + pub code: &'static str, + pub singleton: String, + pub subject: String, + pub path: Vec, + pub file: String, + pub line: u32, +} + +fn by_name(services: &[Service]) -> BTreeMap<&str, &Service> { + services.iter().map(|s| (s.name.as_str(), s)).collect() +} + +fn sort_findings(f: &mut [DiFinding]) { + // Python sorts each finder's output by (file, line, singleton, captured). + f.sort_by(|a, b| { + a.file + .cmp(&b.file) + .then_with(|| a.line.cmp(&b.line)) + .then_with(|| a.singleton.cmp(&b.singleton)) + .then_with(|| a.subject.cmp(&b.subject)) + }); +} + +/// The DI004/DI005 primary anchor: the call/store `site` for the entry `type` +/// when its line ≥ 1, else the registration `(file, line)` fallback (Python's +/// `_di004_primary` / `_di005_primary`). +fn primary_from_site( + sites: &[SiteTriple], + entry: &str, + reg_file: &str, + reg_line: u32, +) -> (String, u32) { + // Python builds `{t: (f, ln) for (t, f, ln) in sites}` — a duplicate entry + // type is **last-wins** — then `.get(entry)` and uses it only when its line + // is >= 1 (registration fallback otherwise). Take the LAST matching site + // (iterate reversed, match on type), then apply the same `>= 1` guard, so a + // trailing line-0 site falls back exactly as Python does. + match sites.iter().rev().find(|(ty, _, _)| ty == entry) { + Some((_, file, line)) if *line >= 1 => (file.clone(), *line), + _ => (reg_file.to_owned(), reg_line), + } +} + +fn is_singleton(s: &Service) -> bool { + s.lifetime == Some(Lifetime::Singleton) +} + +/// DI001 — every scoped service a singleton reaches through its (transitive, +/// transient-followed) strong dependency chain. +#[must_use] +pub fn find_captive_dependencies(services: &[Service]) -> Vec { + let map = by_name(services); + let mut findings = Vec::new(); + for s in services.iter().filter(|s| is_singleton(s)) { + let mut reported: BTreeSet = BTreeSet::new(); + let mut visited: BTreeSet = BTreeSet::new(); + let mut stack: Vec<(String, Vec)> = vec![(s.name.clone(), vec![s.name.clone()])]; + while let Some((cur, path)) = stack.pop() { + let Some(node) = map.get(cur.as_str()) else { + continue; + }; + for dep in &node.deps { + let Some(dnode) = map.get(dep.as_str()) else { + continue; + }; + let mut npath = path.clone(); + npath.push(dep.clone()); + match dnode.lifetime { + Some(Lifetime::Scoped) => { + if reported.insert(dep.clone()) { + findings.push(DiFinding { + code: "DI001", + singleton: s.name.clone(), + subject: dep.clone(), + path: npath, + file: s.file.clone(), + line: s.line, + }); + } + // the violating edge is found; don't recurse past it. + } + Some(Lifetime::Transient) if visited.insert(dep.clone()) => { + stack.push((dep.clone(), npath)); + } + _ => {} + } + } + } + } + sort_findings(&mut findings); + findings +} + +/// DI002 — every scoped service a singleton reaches via a `WeakReference` +/// entry edge, following strong transient edges thereafter. +#[must_use] +pub fn find_weak_captive_dependencies(services: &[Service]) -> Vec { + let map = by_name(services); + let mut findings = Vec::new(); + for s in services.iter().filter(|s| is_singleton(s)) { + let mut reported: BTreeSet = BTreeSet::new(); + let mut visited: BTreeSet = BTreeSet::new(); + let mut stack: Vec<(String, Vec)> = s + .weak_deps + .iter() + .map(|dep| (dep.clone(), vec![s.name.clone(), dep.clone()])) + .collect(); + while let Some((cur, path)) = stack.pop() { + let Some(cnode) = map.get(cur.as_str()) else { + continue; + }; + match cnode.lifetime { + Some(Lifetime::Scoped) => { + if reported.insert(cur.clone()) { + findings.push(DiFinding { + code: "DI002", + singleton: s.name.clone(), + subject: cur.clone(), + path, + file: s.file.clone(), + line: s.line, + }); + } + } + Some(Lifetime::Transient) if visited.insert(cur.clone()) => { + for d in &cnode.deps { + let mut npath = path.clone(); + npath.push(d.clone()); + stack.push((d.clone(), npath)); + } + } + _ => {} + } + } + } + sort_findings(&mut findings); + findings +} + +/// DI003 — every transient `IDisposable` captured by a singleton (held to app +/// shutdown), following the transient chain. +#[must_use] +pub fn find_captured_transient_disposables(services: &[Service]) -> Vec { + let map = by_name(services); + let mut findings = Vec::new(); + for s in services.iter().filter(|s| is_singleton(s)) { + let mut reported: BTreeSet = BTreeSet::new(); + let mut visited: BTreeSet = BTreeSet::new(); + let mut stack: Vec<(String, Vec)> = vec![(s.name.clone(), vec![s.name.clone()])]; + while let Some((cur, path)) = stack.pop() { + let Some(node) = map.get(cur.as_str()) else { + continue; + }; + for dep in &node.deps { + let Some(dnode) = map.get(dep.as_str()) else { + continue; + }; + if dnode.lifetime != Some(Lifetime::Transient) { + continue; // scoped -> DI001; singleton -> its own pass + } + let mut npath = path.clone(); + npath.push(dep.clone()); + if dnode.disposable && reported.insert(dep.clone()) { + findings.push(DiFinding { + code: "DI003", + singleton: s.name.clone(), + subject: dep.clone(), + path: npath.clone(), + file: s.file.clone(), + line: s.line, + }); + } + if visited.insert(dep.clone()) { + stack.push((dep.clone(), npath)); + } + } + } + } + sort_findings(&mut findings); + findings +} + +/// DI004 — every transient `IDisposable` a singleton hand-resolves from its root +/// provider, following the transient subtree the root builds. +#[must_use] +pub fn find_explicit_root_resolutions(services: &[Service]) -> Vec { + let map = by_name(services); + let mut findings = Vec::new(); + for s in services.iter().filter(|s| is_singleton(s)) { + let mut reported: BTreeSet = BTreeSet::new(); + let mut visited: BTreeSet = BTreeSet::new(); + let mut stack: Vec<(String, Vec)> = s + .root_resolves + .iter() + .map(|t| (t.clone(), vec![s.name.clone(), t.clone()])) + .collect(); + while let Some((cur, path)) = stack.pop() { + let Some(node) = map.get(cur.as_str()) else { + continue; + }; + if node.lifetime != Some(Lifetime::Transient) { + continue; // only transients are root-built/tracked (scoped is DI001's) + } + if node.disposable && reported.insert(cur.clone()) { + // DI004 anchors at the root-resolution call site of the ENTRY type + // (path[1]), even when the disposable is dragged in transitively; + // registration is the fallback when the site is unknown. + let entry = path.get(1).map_or("", String::as_str); + let (pf, pl) = primary_from_site(&s.root_resolve_sites, entry, &s.file, s.line); + findings.push(DiFinding { + code: "DI004", + singleton: s.name.clone(), + subject: cur.clone(), + path: path.clone(), + file: pf, + line: pl, + }); + } + if visited.insert(cur.clone()) { + for dep in &node.deps { + let mut npath = path.clone(); + npath.push(dep.clone()); + stack.push((dep.clone(), npath)); + } + } + } + } + sort_findings(&mut findings); + findings +} + +/// DI005 — every scoped service a singleton reaches by caching, into a field, a +/// value resolved from a scope it creates (through transients). +#[must_use] +pub fn find_scope_cached_captives(services: &[Service]) -> Vec { + let map = by_name(services); + let mut findings = Vec::new(); + for s in services.iter().filter(|s| is_singleton(s)) { + let mut reported: BTreeSet = BTreeSet::new(); + for entry in &s.scope_cached { + if reported.contains(entry) { + continue; + } + let mut visited: BTreeSet = BTreeSet::new(); + let mut stack: Vec<(String, Vec)> = + vec![(entry.clone(), vec![s.name.clone(), entry.clone()])]; + while let Some((cur, path)) = stack.pop() { + let Some(node) = map.get(cur.as_str()) else { + continue; + }; + match node.lifetime { + Some(Lifetime::Scoped) => { + reported.insert(entry.clone()); + // DI005 anchors at the field-store site of the cached ENTRY + // (registration fallback when unknown). + let (pf, pl) = + primary_from_site(&s.scope_cache_sites, entry, &s.file, s.line); + findings.push(DiFinding { + code: "DI005", + singleton: s.name.clone(), + subject: cur.clone(), + path, + file: pf, + line: pl, + }); + break; // first scoped reached — one finding per cached entry + } + Some(Lifetime::Transient) if visited.insert(cur.clone()) => { + for dep in &node.deps { + let mut npath = path.clone(); + npath.push(dep.clone()); + stack.push((dep.clone(), npath)); + } + } + _ => {} + } + } + } + } + sort_findings(&mut findings); + findings +} + +/// All DI findings, combined in the bridge's append order (DI001, DI003, DI002, +/// DI004, DI005) — used by [`di_verdicts`] before the final `(file, line, code)` +/// sort. Mirrors `ownir._di_findings`. +#[must_use] +pub fn all_di_findings(services: &[Service]) -> Vec { + let mut findings = find_captive_dependencies(services); + findings.extend(find_captured_transient_disposables(services)); + findings.extend(find_weak_captive_dependencies(services)); + findings.extend(find_explicit_root_resolutions(services)); + findings.extend(find_scope_cached_captives(services)); + findings +} + +/// The DI verdicts as `(path, line, code)`, sorted by `(file, line, code)`. +/// +/// The #214 comparison surface. `path` is the finding's primary file +/// (registration / resolve site / cache site), part of the verdict identity. +#[must_use] +pub fn di_verdicts(services: &[Service]) -> Vec<(String, u32, &'static str)> { + let mut out: Vec<(String, u32, &'static str)> = all_di_findings(services) + .into_iter() + .map(|f| (f.file, f.line, f.code)) + .collect(); + out.sort_by(|a, b| { + a.0.cmp(&b.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(b.2)) + }); + out +} + +/// Project the DI verdicts to `own-diagnostics` values (code + line). +/// +/// Kept for the eventual bridge integration; `(path, line, code)` parity uses +/// [`di_verdicts`] since `Diagnostic` does not carry the path. +#[must_use] +pub fn check_di(services: &[Service]) -> Vec { + let mut out = Vec::new(); + for (_file, line, code) in di_verdicts(services) { + let msg = title(code).unwrap_or(code); + if let Ok(d) = Diagnostic::new(code, msg, line) { + out.push(d); + } + } + out +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] +mod tests { + use super::{ + find_captive_dependencies, find_captured_transient_disposables, + find_explicit_root_resolutions, find_scope_cached_captives, find_weak_captive_dependencies, + Lifetime, Service, + }; + + fn svc(name: &str, lt: Lifetime, deps: &[&str]) -> Service { + let mut s = Service::new(name, lt, "reg.cs", 1); + s.deps = deps.iter().map(|d| (*d).to_owned()).collect(); + s + } + + #[test] + fn di001_direct_singleton_to_scoped() { + let services = vec![ + svc("App", Lifetime::Singleton, &["Db"]), + svc("Db", Lifetime::Scoped, &[]), + ]; + let f = find_captive_dependencies(&services); + assert_eq!(f.len(), 1); + assert_eq!((f[0].code, f[0].subject.as_str()), ("DI001", "Db")); + } + + #[test] + fn di001_through_a_transient() { + // singleton -> transient -> scoped is captive (the transient is + // singleton-lived and drags the scoped along). + let services = vec![ + svc("App", Lifetime::Singleton, &["Mid"]), + svc("Mid", Lifetime::Transient, &["Db"]), + svc("Db", Lifetime::Scoped, &[]), + ]; + let f = find_captive_dependencies(&services); + assert_eq!(f.len(), 1); + assert_eq!(f[0].subject, "Db"); + } + + #[test] + fn di001_not_followed_through_an_inner_singleton() { + // singleton -> singleton -> scoped: the INNER singleton is the captor, + // reported on its own pass — the outer does not double-report. + let services = vec![ + svc("A", Lifetime::Singleton, &["B"]), + svc("B", Lifetime::Singleton, &["Db"]), + svc("Db", Lifetime::Scoped, &[]), + ]; + let f = find_captive_dependencies(&services); + // Only B -> Db is reported (once), not A -> ... -> Db. + assert_eq!(f.len(), 1); + assert_eq!(f[0].singleton, "B"); + } + + #[test] + fn di002_weak_scoped_capture() { + let mut app = Service::new("App", Lifetime::Singleton, "reg.cs", 1); + app.weak_deps = vec!["Db".to_owned()]; + let services = vec![app, svc("Db", Lifetime::Scoped, &[])]; + let f = find_weak_captive_dependencies(&services); + assert_eq!(f.len(), 1); + assert_eq!((f[0].code, f[0].subject.as_str()), ("DI002", "Db")); + } + + #[test] + fn di003_transient_disposable_capture() { + let mut disp = svc("Conn", Lifetime::Transient, &[]); + disp.disposable = true; + let services = vec![svc("App", Lifetime::Singleton, &["Conn"]), disp]; + let f = find_captured_transient_disposables(&services); + assert_eq!(f.len(), 1); + assert_eq!((f[0].code, f[0].subject.as_str()), ("DI003", "Conn")); + } + + #[test] + fn di004_root_resolved_transient_disposable() { + let mut app = Service::new("App", Lifetime::Singleton, "reg.cs", 1); + app.root_resolves = vec!["Conn".to_owned()]; + let mut disp = svc("Conn", Lifetime::Transient, &[]); + disp.disposable = true; + let services = vec![app, disp]; + let f = find_explicit_root_resolutions(&services); + assert_eq!(f.len(), 1); + assert_eq!((f[0].code, f[0].subject.as_str()), ("DI004", "Conn")); + } + + #[test] + fn di005_scope_cached_scoped_service() { + let mut app = Service::new("App", Lifetime::Singleton, "reg.cs", 1); + app.scope_cached = vec!["Db".to_owned()]; + let services = vec![app, svc("Db", Lifetime::Scoped, &[])]; + let f = find_scope_cached_captives(&services); + assert_eq!(f.len(), 1); + assert_eq!((f[0].code, f[0].subject.as_str()), ("DI005", "Db")); + } + + #[test] + fn no_captive_when_all_singleton() { + let services = vec![ + svc("A", Lifetime::Singleton, &["B"]), + svc("B", Lifetime::Singleton, &[]), + ]; + assert!(find_captive_dependencies(&services).is_empty()); + } +} diff --git a/rust/crates/own-analysis/src/effect.rs b/rust/crates/own-analysis/src/effect.rs new file mode 100644 index 00000000..4a5b74a9 --- /dev/null +++ b/rust/crates/own-analysis/src/effect.rs @@ -0,0 +1,339 @@ +//! Reactive-effect stability analysis (EFF001, the effect storm) — an exact port +//! of `ownlang/effects.py`. +//! +//! A deterministic property of the **render-scope binding graph**: a `useEffect` +//! that performs IO and depends on a name whose identity changes every render (a +//! fresh object/array/`new` literal, directly or via a derivation chain) re-fires +//! every render — a request storm. The stability lattice `STABLE < UNKNOWN < +//! UNSTABLE` is computed to a fixpoint over binding references with a cycle guard. +//! +//! This is a **fact-driven** analysis: the `OwnIR` bridge (own-bridge, step 6) +//! feeds [`Effect`]/[`Binding`] facts; there is no `.own` surface. This module +//! ports the algorithm and pins it with unit tests; its end-to-end diagnostic +//! parity lands with the bridge. `#[allow(clippy::panic)]`-free; `(line, code)` +//! is the parity contract (EFF001 anchors at the effect's call line). + +use std::collections::{BTreeMap, BTreeSet}; + +use own_diagnostics::{title, Diagnostic}; + +/// The stability lattice (join = worst case): `Stable < Unknown < Unstable`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Stability { + Stable, + Unknown, + Unstable, +} + +impl Stability { + const fn rank(self) -> u8 { + match self { + Self::Stable => 0, + Self::Unknown => 1, + Self::Unstable => 2, + } + } +} + +/// One render-scope binding: `name` bound to an initialiser of kind `init`, which +/// may reference other binding names (`refs`). Mirrors `effects.Binding`. +#[derive(Debug, Clone)] +pub struct Binding { + pub name: String, + pub init: String, + pub refs: Vec, + pub line: u32, +} + +/// One `useEffect`, mirroring `effects.Effect`. +/// +/// The deps it declares, whether its body does IO, and the render-scope bindings +/// visible to it. `file`/`line` are the effect's call site — the finding's +/// primary `(path, line)`. +#[derive(Debug, Clone)] +pub struct Effect { + pub component: String, + pub deps: Vec, + pub io: bool, + pub bindings: Vec, + pub file: String, + pub line: u32, +} + +/// An EFF001 finding: the effect whose IO re-fires and the unstable dependency. +/// +/// `origin` is the upstream binding whose fresh identity is the root cause; +/// `file`/`line` are the verdict's primary `(path, line)` — the effect call site. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectStorm { + pub component: String, + pub dep: String, + pub origin: String, + pub file: String, + pub line: u32, +} + +fn kind_stability(init: &str) -> Option { + match init { + "object" | "array" | "new" => Some(Stability::Unstable), + "memo" | "callback" | "ref" | "prop" | "state" | "primitive" | "import" | "fn" + | "param" => Some(Stability::Stable), + // "ident"/"spread"/"ternary"/"derive" join over refs; handled by the caller. + _ => None, + } +} + +fn is_derived(init: &str) -> bool { + matches!(init, "ident" | "spread" | "ternary" | "derive") +} + +/// A plain identifier or member chain (`tenantId`, `props.id`) — referentially +/// stable when it has no render-scope binding. Port of the `_IDENT` regex +/// `^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$`. +fn is_ident_chain(s: &str) -> bool { + if s.is_empty() { + return false; + } + let seg_ok = |seg: &str| { + let mut chars = seg.chars(); + match chars.next() { + Some(c) if c == '_' || c == '$' || c.is_ascii_alphabetic() => {} + _ => return false, + } + chars.all(|c| c == '_' || c == '$' || c.is_ascii_alphanumeric()) + }; + s.split('.').all(seg_ok) +} + +/// Stability of each binding name, resolved to a fixpoint with memoization and a +/// cycle guard, recording the upstream `origin` of an unstable name. Port of +/// `effects._Lattice`. +struct Lattice<'a> { + by_name: BTreeMap<&'a str, &'a Binding>, + stab: BTreeMap, + origin: BTreeMap, +} + +impl<'a> Lattice<'a> { + fn new(bindings: &'a [Binding]) -> Self { + Self { + by_name: bindings.iter().map(|b| (b.name.as_str(), b)).collect(), + stab: BTreeMap::new(), + origin: BTreeMap::new(), + } + } + + fn stability(&mut self, name: &str) -> Stability { + self.resolve(name, &BTreeSet::new()).0 + } + + fn origin_of(&self, name: &str) -> String { + self.origin + .get(name) + .cloned() + .unwrap_or_else(|| name.to_owned()) + } + + fn resolve(&mut self, name: &str, on_stack: &BTreeSet) -> (Stability, String) { + if let Some(s) = self.stab.get(name) { + return (*s, self.origin_of(name)); + } + let Some(b) = self.by_name.get(name).copied() else { + // No render-scope binding: an identifier/member chain is referentially + // stable; anything else (a literal/ctor/call) stays conservative. + let stab = if is_ident_chain(name) { + Stability::Stable + } else { + Stability::Unknown + }; + return (stab, name.to_owned()); + }; + if on_stack.contains(name) { + // an identity cycle (a = b; b = a): cannot prove unstable — stay safe. + return (Stability::Unknown, name.to_owned()); + } + let mut next_stack = on_stack.clone(); + next_stack.insert(name.to_owned()); + let (stab, origin) = self.classify(b, &next_stack); + self.stab.insert(name.to_owned(), stab); + self.origin.insert(name.to_owned(), origin.clone()); + (stab, origin) + } + + fn classify(&mut self, b: &Binding, on_stack: &BTreeSet) -> (Stability, String) { + if let Some(s) = kind_stability(&b.init) { + return (s, b.name.clone()); + } + if is_derived(&b.init) { + if b.refs.is_empty() { + return (Stability::Unknown, b.name.clone()); + } + let mut worst = Stability::Stable; + let mut worst_origin = b.name.clone(); + for r in &b.refs { + let (s, o) = self.resolve(r, on_stack); + if s.rank() > worst.rank() { + worst = s; + worst_origin = o; + } + } + return (worst, worst_origin); + } + // "call" or any unrecognised kind: opaque identity -> conservative. + (Stability::Unknown, b.name.clone()) + } +} + +/// Every EFF001 effect storm: an IO effect with a provably `Unstable` dependency +/// (the first one). Deterministic, sorted by `(line, dep)`. Port of +/// `find_effect_storms`. +#[must_use] +pub fn find_effect_storms(effects: &[Effect]) -> Vec { + let mut out: Vec = Vec::new(); + for e in effects { + if !e.io { + continue; + } + let mut lat = Lattice::new(&e.bindings); + for dep in &e.deps { + if lat.stability(dep) != Stability::Unstable { + continue; + } + out.push(EffectStorm { + component: e.component.clone(), + dep: dep.clone(), + origin: lat.origin_of(dep), + file: e.file.clone(), + line: e.line, + }); + break; // one finding per effect + } + } + // Python sorts by (file, line, dep) — file is verdict identity, not metadata. + out.sort_by(|a, b| { + a.file + .cmp(&b.file) + .then_with(|| a.line.cmp(&b.line)) + .then_with(|| a.dep.cmp(&b.dep)) + }); + out +} + +/// The EFF001 verdicts as `(path, line, code)` — the #214 comparison surface. +/// `path` is the effect's file (a fact-set can span files), so it is part of the +/// verdict identity, not presentation. +#[must_use] +pub fn effect_verdicts(effects: &[Effect]) -> Vec<(String, u32, &'static str)> { + find_effect_storms(effects) + .into_iter() + .map(|s| (s.file, s.line, "EFF001")) + .collect() +} + +/// Project the effect storms to `(line, EFF001)` diagnostics — the #214 verdict +/// surface (the bridge does final anchoring/evidence at step 6). +#[must_use] +pub fn effect_diagnostics(effects: &[Effect]) -> Vec { + let mut out = Vec::new(); + for storm in find_effect_storms(effects) { + let msg = title("EFF001").unwrap_or("EFF001"); + if let Ok(d) = Diagnostic::new("EFF001", msg, storm.line) { + out.push(d); + } + } + out +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] +mod tests { + use super::{find_effect_storms, Binding, Effect}; + + fn binding(name: &str, init: &str, refs: &[&str]) -> Binding { + Binding { + name: name.to_owned(), + init: init.to_owned(), + refs: refs.iter().map(|s| (*s).to_owned()).collect(), + line: 1, + } + } + + fn effect(deps: &[&str], io: bool, bindings: Vec) -> Effect { + Effect { + component: "C".to_owned(), + deps: deps.iter().map(|s| (*s).to_owned()).collect(), + io, + bindings, + file: "C.tsx".to_owned(), + line: 10, + } + } + + #[test] + fn fresh_object_dep_with_io_is_a_storm() { + let e = effect(&["opts"], true, vec![binding("opts", "object", &[])]); + let storms = find_effect_storms(&[e]); + assert_eq!(storms.len(), 1); + assert_eq!(storms[0].dep, "opts"); + assert_eq!(storms[0].line, 10); + } + + #[test] + fn memoised_dep_is_clean() { + let e = effect(&["opts"], true, vec![binding("opts", "memo", &[])]); + assert!(find_effect_storms(&[e]).is_empty()); + } + + #[test] + fn no_io_never_fires() { + let e = effect(&["opts"], false, vec![binding("opts", "object", &[])]); + assert!(find_effect_storms(&[e]).is_empty()); + } + + #[test] + fn instability_propagates_through_a_derivation_chain() { + // a = {..}(unstable); b = a; c = b — c is unstable, origin a. + let e = effect( + &["c"], + true, + vec![ + binding("a", "object", &[]), + binding("b", "ident", &["a"]), + binding("c", "ident", &["b"]), + ], + ); + let storms = find_effect_storms(&[e]); + assert_eq!(storms.len(), 1); + assert_eq!(storms[0].dep, "c"); + assert_eq!(storms[0].origin, "a"); + } + + #[test] + fn opaque_call_is_unknown_not_a_storm() { + // a call return has unknown identity — conservative, no false positive. + let e = effect(&["x"], true, vec![binding("x", "call", &[])]); + assert!(find_effect_storms(&[e]).is_empty()); + } + + #[test] + fn plain_identifier_dep_without_binding_is_stable() { + let e = effect(&["props.id"], true, vec![]); + assert!(find_effect_storms(&[e]).is_empty()); + } + + #[test] + fn identity_cycle_stays_safe() { + // a = b; b = a (both ident derivations) — cannot prove unstable. + let e = effect( + &["a"], + true, + vec![binding("a", "ident", &["b"]), binding("b", "ident", &["a"])], + ); + assert!(find_effect_storms(&[e]).is_empty()); + } +} diff --git a/rust/crates/own-analysis/src/lib.rs b/rust/crates/own-analysis/src/lib.rs new file mode 100644 index 00000000..499c6b73 --- /dev/null +++ b/rust/crates/own-analysis/src/lib.rs @@ -0,0 +1,27 @@ +//! `own-analysis` — the analysis heart (P-022 step 4, issue #214). +//! +//! Two layers, matching the Python `ownlang.analysis` split: +//! +//! * [`solver`] — a generic monotone-lattice forward worklist solver (the +//! `_Analyzer.fixpoint` shape), domain-agnostic and dependency-free. +//! * the domain analyses (ownership first, then lifetime/effect/DI) built on the +//! solver, consuming an [`own_cfg`] CFG and constructing [`own_diagnostics`] +//! verdicts. +//! +//! Python stays authoritative; the diagnostics differential oracle +//! (`tests/parity.rs`, replaying `tests/fixtures/diag_parity.json`) pins the +//! Rust output to it on `(path, line, code)`. + +pub mod check; +pub mod di; +pub mod effect; +pub mod lifetime; +pub mod ownership; +pub mod solver; + +pub use check::check_module; +pub use di::{check_di, di_verdicts}; +pub use effect::{effect_diagnostics, effect_verdicts, find_effect_storms}; +pub use lifetime::check_lifetimes; +pub use ownership::analyze; +pub use solver::{solve, solve_with, Analysis, ControlFlowGraph, Lattice, Schedule, Solution}; diff --git a/rust/crates/own-analysis/src/lifetime.rs b/rust/crates/own-analysis/src/lifetime.rs new file mode 100644 index 00000000..e8a95a40 --- /dev/null +++ b/rust/crates/own-analysis/src/lifetime.rs @@ -0,0 +1,235 @@ +//! Lifetime-region analysis — an exact port of `ownlang/lifetimes.py`. +//! +//! This is an **AST-level** analysis (not CFG/solver based): it reasons about +//! *region escape* — the WPF "zombie `ViewModel`" theorem. `lifetime` +//! declarations define a strict partial order; a `subscribe self to source` +//! where the source outlives the captured object promotes the object to the +//! 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. + +use std::collections::{BTreeMap, BTreeSet}; + +use own_cfg::ast::{FnDecl, LifetimeDecl, Module, Stmt, Subscribe}; +use own_diagnostics::{title, Diagnostic}; + +/// Emit a `(code, line)` diagnostic (message is the title; text parity is a +/// later step). `code` is a compile-time constant, so `new` cannot fail. +fn push(diags: &mut Vec, code: &'static str, line: u32) { + let msg = title(code).unwrap_or(code); + match Diagnostic::new(code, msg, line) { + Ok(d) => diags.push(d), + Err(_) => debug_assert!(false, "lifetime analysis emitted an unknown code {code}"), + } +} + +/// Collect every `subscribe` in a body, descending into branches/loops/borrow +/// blocks — the port of `_iter_subscribes`. +fn collect_subscribes<'a>(stmts: &'a [Stmt], out: &mut Vec<&'a Subscribe>) { + for st in stmts { + match st { + Stmt::Subscribe(s) => out.push(s), + Stmt::If(i) => { + collect_subscribes(&i.then_body, out); + collect_subscribes(&i.else_body, out); + } + Stmt::While(w) => collect_subscribes(&w.body, out), + Stmt::BorrowBlock(b) => collect_subscribes(&b.body, out), + _ => {} + } + } +} + +/// Map each region to the set of regions strictly longer-lived than it (the +/// transitive closure of `<`) — the port of `_strictly_longer`. +fn strictly_longer(decls: &[LifetimeDecl]) -> BTreeMap> { + let mut direct: BTreeMap> = BTreeMap::new(); + for d in decls { + direct.entry(d.name.clone()).or_default(); + if let Some(longer) = &d.longer { + direct.entry(longer.clone()).or_default(); + if let Some(set) = direct.get_mut(&d.name) { + set.insert(longer.clone()); + } + } + } + + let mut longer: BTreeMap> = direct + .keys() + .map(|n| (n.clone(), BTreeSet::new())) + .collect(); + for start in direct.keys() { + let mut stack: Vec = direct + .get(start) + .map(|s| s.iter().cloned().collect()) + .unwrap_or_default(); + while let Some(cur) = stack.pop() { + let already = longer.get(start).is_some_and(|s| s.contains(&cur)); + if already { + continue; + } + if let Some(set) = longer.get_mut(start) { + set.insert(cur.clone()); + } + if let Some(next) = direct.get(&cur) { + stack.extend(next.iter().cloned()); + } + } + } + longer +} + +/// Region diagnostics for a module: structural validation of the lifetime order +/// plus the per-function escape check. Port of `check_lifetimes`. +/// +/// Does NOT early-return on empty `lifetimes` — a function/parameter may still +/// carry an annotation referencing an undeclared region (OWN030). +#[must_use] +pub fn check_lifetimes(module: &Module) -> Vec { + let mut diags: Vec = Vec::new(); + + let mut names: BTreeSet = BTreeSet::new(); + for d in &module.lifetimes { + if names.contains(&d.name) { + push(&mut diags, "OWN031", d.line); + } + names.insert(d.name.clone()); + } + for d in &module.lifetimes { + if let Some(longer) = &d.longer { + if !names.contains(longer) { + push(&mut diags, "OWN030", d.line); + } + } + } + + let longer = strictly_longer(&module.lifetimes); + for d in &module.lifetimes { + // a cycle shows up as a region being strictly longer than itself. + if longer.get(&d.name).is_some_and(|s| s.contains(&d.name)) { + push(&mut diags, "OWN036", d.line); + } + } + + for f in &module.functions { + check_fn(f, &names, &longer, &mut diags); + } + diags +} + +fn check_fn( + f: &FnDecl, + names: &BTreeSet, + longer: &BTreeMap>, + diags: &mut Vec, +) { + // validate annotations on this function, even with no subscribes. + if let Some(lt) = &f.lifetime { + if !names.contains(lt) { + push(diags, "OWN030", f.line); + } + } + let mut param_lt: BTreeMap = BTreeMap::new(); + for p in &f.params { + if let Some(lt) = &p.lifetime { + if names.contains(lt) { + param_lt.insert(p.name.clone(), lt.clone()); + } else { + push(diags, "OWN030", p.line); + } + } + } + + let self_lt = f.lifetime.as_ref().filter(|lt| names.contains(lt.as_str())); + let mut subs: Vec<&Subscribe> = Vec::new(); + collect_subscribes(&f.body, &mut subs); + for sub in subs { + // skip when we cannot compare (no self lifetime / untagged source): + // conservative, avoids false positives. + let (Some(self_lt), Some(src_lt)) = (self_lt, param_lt.get(&sub.source)) else { + continue; + }; + if longer.get(self_lt).is_some_and(|s| s.contains(src_lt)) { + push(diags, "OWN014", sub.line); + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::check_lifetimes; + + fn codes(src: &str) -> Vec<(u32, String)> { + let module = own_syntax::parse(src).expect("parses"); + let mut v: Vec<(u32, String)> = check_lifetimes(&module) + .into_iter() + .map(|d| (d.line, d.code)) + .collect(); + v.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + v + } + + #[test] + fn region_escape_is_own014() { + // bus (App) strictly outlives self (ViewModel) → the strong subscribe + // promotes the object and leaks. + let src = "module M\n\ + lifetime App;\n\ + lifetime Window < App;\n\ + lifetime ViewModel < Window;\n\ + fn VM(bus: EventBus lifetime App) lifetime ViewModel {\n\ + subscribe self to bus;\n\ + }\n"; + assert_eq!(codes(src), vec![(6, "OWN014".to_owned())]); + } + + #[test] + fn equal_or_shorter_source_lifetime_is_clean() { + // source at the SAME region as self → no promotion, no finding. + let src = "module M\n\ + lifetime App;\n\ + lifetime ViewModel < App;\n\ + fn VM(peer: Other lifetime ViewModel) lifetime ViewModel {\n\ + subscribe self to peer;\n\ + }\n"; + assert!(codes(src).is_empty(), "equal-lifetime capture is fine"); + } + + #[test] + fn cyclic_ordering_is_own036() { + // A < B and B < A → both end up strictly longer than themselves. + let src = "module M\nlifetime A < B;\nlifetime B < A;\n"; + assert_eq!( + codes(src), + vec![(2, "OWN036".to_owned()), (3, "OWN036".to_owned())] + ); + } + + #[test] + fn undefined_and_redeclared_lifetimes() { + // `< Ghost` references an undeclared region (OWN030); `Dup` twice (OWN031). + let src = "module M\n\ + lifetime Dup;\n\ + lifetime Dup;\n\ + lifetime Short < Ghost;\n"; + assert_eq!( + codes(src), + vec![(3, "OWN031".to_owned()), (4, "OWN030".to_owned())] + ); + } + + #[test] + fn subscribe_descends_into_branches() { + // the escaping subscribe is nested inside an `if` — still found. + let src = "module M\n\ + lifetime App;\n\ + lifetime ViewModel < App;\n\ + fn VM(bus: EventBus lifetime App) lifetime ViewModel {\n\ + if (x) { subscribe self to bus; }\n\ + }\n"; + assert_eq!(codes(src), vec![(5, "OWN014".to_owned())]); + } +} diff --git a/rust/crates/own-analysis/src/ownership.rs b/rust/crates/own-analysis/src/ownership.rs new file mode 100644 index 00000000..ff615d05 --- /dev/null +++ b/rust/crates/own-analysis/src/ownership.rs @@ -0,0 +1,1013 @@ +//! 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 +//! human-readable title as its message so it is never blank). +//! +//! RID semantics map directly: Python keys resource state on `id(sym)`; here a +//! RID is the resource's [`SymId`] (`u32`), minted deterministically by `own-cfg` +//! (whose symbol table is itself pinned byte-for-byte by the CFG-JSON oracle), so +//! the whole aliasing/loan structure is reproduced without object identity. +//! +//! [`solver`]: crate::solver + +use std::collections::{BTreeMap, BTreeSet}; + +use own_cfg::{Cfg, Effect, Instr, Kind, SymId}; +use own_diagnostics::{title, Diagnostic}; + +use crate::solver::{solve, Analysis, ControlFlowGraph, Lattice}; + +// Per-resource variable-state, as a bitset over `{OWNED, MOVED, RELEASED, +// ESCAPED}` (the P-022 `u8`-bitflags lattice; Python's `set[VarState]`). +const OWNED: u8 = 1; +const MOVED: u8 = 2; +const RELEASED: u8 = 4; +const ESCAPED: u8 = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LoanKind { + Shared, + Mut, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Loan { + /// The owner's RID — so the loan is seen through every owning alias. + owner: u32, + kind: LoanKind, +} + +/// The dataflow fact: per-RID variable-states, active loans, the handle→RID +/// aliasing map, and move/acquire provenance. A faithful port of `analysis.State`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct State { + /// RID → variable-state bitset. **Absent key reads as `OWNED`** (Python + /// `st.var.get(rid, {OWNED})`); at a **join**, an absent key contributes the + /// empty set (Python `a.var.get(k, set())`). + var: BTreeMap, + /// Binding-RID → loan (keyed by the borrow binding's id). + loans: BTreeMap, + /// Handle id → RID (aliasing; default 1:1, i.e. a handle denotes itself). + handle_rid: BTreeMap, + /// RID → (line, exact) move-site provenance (OWN005 evidence only). + moved_at: BTreeMap, + /// RID → (line, exact) acquire-site provenance (OWN001 evidence only). + acquired_at: BTreeMap, +} + +impl State { + /// A concrete, reachable state with no facts yet — the initial-state seed and + /// the Python `State()` fallback. This is **not** the lattice ⊥ (that is + /// [`StateFact::Bottom`]); a reachable block whose data is empty is a real, + /// distinct value that still enforces the merge invariant. + const fn empty() -> Self { + Self { + var: BTreeMap::new(), + loans: BTreeMap::new(), + handle_rid: BTreeMap::new(), + moved_at: BTreeMap::new(), + acquired_at: BTreeMap::new(), + } + } + + fn rid_of(&self, sym: SymId) -> u32 { + self.handle_rid.get(&sym.0).copied().unwrap_or(sym.0) + } + + fn mint(&mut self, sym: SymId) -> u32 { + self.handle_rid.insert(sym.0, sym.0); + sym.0 + } + + /// The variable-state of `rid` on read — **defaults to `OWNED`** when absent. + fn states(&self, rid: u32) -> u8 { + self.var.get(&rid).copied().unwrap_or(OWNED) + } +} + +/// Merge two RID→(line, exact) provenance maps (`analysis._join_sites`): agree on +/// a line ⇒ keep it, `exact` AND-ed; disagree ⇒ keep the earliest line, inexact. +fn join_sites(out: &mut BTreeMap, other: &BTreeMap) { + for (&rid, &(line_b, exact_b)) in other { + match out.get(&rid).copied() { + Some((line_a, exact_a)) => { + let merged = if line_a == line_b { + (line_a, exact_a && exact_b) + } else { + (line_a.min(line_b), false) + }; + out.insert(rid, merged); + } + None => { + out.insert(rid, (line_b, exact_b)); + } + } + } +} + +impl State { + /// Merge another concrete predecessor state into this one (the real join over + /// two *reachable* states), returning whether `self` changed. **No** bottom + /// special-casing lives here — that is [`StateFact`]'s job — so the merge + /// invariant is enforced unconditionally, even when one side is concretely + /// empty (a real empty predecessor with mismatched loans still fails loudly). + fn join_data(&mut self, other: &Self) -> bool { + let before = self.clone(); + + // var: union of the per-RID bitsets; absent key contributes ∅ (0). + let keys: BTreeSet = self.var.keys().chain(other.var.keys()).copied().collect(); + for k in keys { + let merged = + self.var.get(&k).copied().unwrap_or(0) | other.var.get(&k).copied().unwrap_or(0); + self.var.insert(k, merged); + } + + // Block-scoped borrows ⇒ identical active loans on both predecessors + // (holds across loop back-edges: a borrow closes within its own scope). + // FULL-VALUE equality (owner + kind per binding), not just the key set, + // so a same-binding / different-owner-or-kind merge — OR an empty-loan vs + // non-empty-loan merge — fails loudly instead of silently picking a side, + // matching the Python `join` (tightened in lockstep). In practice this is + // `{} == {}` (loans close within a block). + assert!( + self.loans == other.loans, + "active loans differ at a control-flow merge; impossible for \ + block-scoped borrows (they close within the scope that opened them)" + ); + + // handle_rid: union; a handle must map to the same RID on both paths + // (the step-0 1:1 invariant), asserted rather than silently picking a side. + for (&handle, &rid) in &other.handle_rid { + match self.handle_rid.get(&handle).copied() { + Some(existing) => assert!( + existing == rid, + "a handle maps to two different RIDs at a merge (1:1 invariant broken)" + ), + None => { + self.handle_rid.insert(handle, rid); + } + } + } + + join_sites(&mut self.moved_at, &other.moved_at); + join_sites(&mut self.acquired_at, &other.acquired_at); + + *self != before + } +} + +/// The dataflow **fact** — an EXPLICIT lattice with `Bottom` kept *separate* from +/// a concrete (reachable) state, so structural emptiness never masquerades as ⊥. +/// +/// This is the load-bearing distinction: `Reachable(State::empty())` is a real +/// predecessor value, **not** the lattice bottom, so joining it with a +/// loan-carrying state still runs the merge invariant (rather than short- +/// circuiting as an identity). `Bottom` is only ever the seed for a block none of +/// whose predecessors have been evaluated yet. +#[derive(Debug, Clone, PartialEq, Eq)] +enum StateFact { + /// ⊥ — no predecessor evaluated yet. The identity for [`Lattice::join`]. + Bottom, + /// A concrete, reachable dataflow state. + Reachable(State), +} + +impl Lattice for StateFact { + fn bottom() -> Self { + Self::Bottom + } + + fn join(&mut self, other: &Self) -> bool { + match (&mut *self, other) { + // x ∨ ⊥ = x (no change) + (_, Self::Bottom) => false, + // ⊥ ∨ x = x (grows from bottom, even when x carries active loans) + (Self::Bottom, o) => { + *self = o.clone(); + true + } + // Concrete ∨ Concrete: the real merge, invariant always enforced. + (Self::Reachable(a), Self::Reachable(b)) => a.join_data(b), + } + } +} + +/// A collector that emits diagnostics in phase 2 and is a no-op during the silent +/// fixpoint (phase 1) — the port of `_Analyzer.silent`. +enum Emit<'a> { + Silent, + Collect(&'a mut Vec), +} + +impl Emit<'_> { + fn push(&mut self, code: &'static str, line: u32) { + if let Self::Collect(sink) = 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), + Err(_) => debug_assert!(false, "own-analysis emitted an unknown code {code}"), + } + } + } +} + +// -- loan / permission helpers (port of the `self`-less `_Analyzer` methods) -- + +/// Loans on `owner`'s RID: `(shared_count, has_mut)`. A loan is keyed by the +/// owner's RID, so a borrow of one owning alias is seen through all aliases. +fn loans_on(st: &State, owner: SymId) -> (u32, bool) { + let owner_rid = st.rid_of(owner); + let mut shared: u32 = 0; + let mut mutable = false; + for ln in st.loans.values() { + if ln.owner == owner_rid { + match ln.kind { + LoanKind::Shared => shared = shared.saturating_add(1), + LoanKind::Mut => mutable = true, + } + } + } + (shared, mutable) +} + +/// The shared gone / maybe-gone classification (`_state_problem`); returns +/// whether a problem was reported for an operation on owned symbol `sym`. +/// +/// Note: the "consumed" and "released" cases both emit OWN002 (only the message +/// differs in Python, and message text is not part of the checkpoint-2 contract). +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); + } else { + emit.push("OWN002", line); + } + return true; + } + if s & (RELEASED | ESCAPED) != 0 { + emit.push("OWN009", line); + return true; + } + if s & MOVED != 0 { + emit.push("OWN010", line); + return true; + } + false +} + +/// move / consume: needs Own permission (no loans). +fn consume_like( + st: &State, + sym: SymId, + emit: &mut Emit<'_>, + line: u32, + code_borrowed: &'static str, +) { + if state_problem(st, sym, emit, line) { + return; + } + let (shared, mutable) = loans_on(st, sym); + if shared > 0 || mutable { + emit.push(code_borrowed, line); + } +} + +fn check_mut_borrowable(st: &State, owner: SymId, emit: &mut Emit<'_>, line: u32) { + if state_problem(st, owner, emit, line) { + return; + } + let (shared, mutable) = loans_on(st, owner); + if shared > 0 { + emit.push("OWN006", line); + } else if mutable { + emit.push("OWN011", line); + } +} + +fn check_shared_borrowable(st: &State, owner: SymId, emit: &mut Emit<'_>, line: u32) { + if state_problem(st, owner, emit, line) { + return; + } + let (_shared, mutable) = loans_on(st, owner); + if mutable { + emit.push("OWN012", line); + } +} + +/// Report every RID still `OWNED` in `st` as a leak (OWN001), excluding a +/// returned resource. Port of `_Analyzer.leak_check`. +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); + } + } +} + +/// The ownership analyzer over one function's CFG. Implements both the solver's +/// [`ControlFlowGraph`] (from the CFG shape) and [`Analysis`] (the silent +/// transfer that converges the in-states). +struct Ownership<'a> { + cfg: &'a Cfg, + /// Per-block successor ids as `usize` (the solver's graph view). + succ: Vec>, +} + +impl<'a> Ownership<'a> { + fn new(cfg: &'a Cfg) -> Self { + let succ = cfg + .blocks + .iter() + .map(|b| b.succ.iter().map(|s| s.0 as usize).collect()) + .collect(); + Self { cfg, succ } + } + + fn initial_state(&self) -> State { + let mut s = State::empty(); + for &pid in &self.cfg.params { + if self.cfg.symbol(pid).kind == Kind::Owned { + let rid = s.mint(pid); + s.var.insert(rid, OWNED); + } + } + s + } + + // -- loan / permission helpers (the `self`-less ones are free fns below) -- + + fn binding_live(&self, st: &State, sym: SymId) -> bool { + self.cfg.symbol(sym).is_param_borrow || st.loans.contains_key(&sym.0) + } + + fn apply_effect( + &self, + st: &mut State, + sym: SymId, + eff: Effect, + emit: &mut Emit<'_>, + line: u32, + ) { + let s = self.cfg.symbol(sym); + match s.kind { + Kind::Owned => match eff { + Effect::Consume => { + consume_like(st, sym, emit, line, "OWN007"); + if let Some(buf) = &s.buffer { + if buf.stack_backed() { + emit.push("OWN016", line); + } else { + emit.push("OWN017", line); + } + } + let rid = st.rid_of(sym); + st.var.insert(rid, ESCAPED); + } + Effect::BorrowMut => check_mut_borrowable(st, sym, emit, line), + Effect::Borrow => check_shared_borrowable(st, sym, emit, line), + Effect::Plain => emit.push("OWN041", line), + }, + Kind::Borrow => match eff { + Effect::Borrow => { + if !self.binding_live(st, sym) { + emit.push("OWN004", line); + } + } + Effect::BorrowMut => { + if s.borrow_is_mut == Some(false) { + emit.push("OWN041", line); + } + } + Effect::Consume => emit.push("OWN034", line), + Effect::Plain => emit.push("OWN041", line), + }, + Kind::Plain => { + if matches!(eff, Effect::Borrow | Effect::BorrowMut | Effect::Consume) { + emit.push("OWN041", line); + } + } + } + } + + // -- transfer (the step() dispatch) ------------------------------------ + + // One large `match` faithfully dispatching every `Instr` variant, mirroring + // the Python `step()`; splitting it would obscure the 1:1 port. + #[allow(clippy::too_many_lines)] + fn step(&self, ins: &Instr, st: &mut State, emit: &mut Emit<'_>) { + match ins { + Instr::Acquire { sym, line, .. } | Instr::AcquireBuffer { sym, line, .. } => { + let rid = st.mint(*sym); + st.var.insert(rid, OWNED); + st.acquired_at.insert(rid, (*line, true)); + } + Instr::MoveInto { dst, src, line } => { + consume_like(st, *src, emit, *line, "OWN007"); + let src_rid = st.rid_of(*src); + if st.states(src_rid) & OWNED != 0 { + st.moved_at.insert(src_rid, (*line, true)); + } + st.var.insert(src_rid, MOVED); + let dst_rid = st.mint(*dst); + st.var.insert(dst_rid, OWNED); + st.acquired_at.insert(dst_rid, (*line, true)); + } + Instr::AliasJoin { handle, src, .. } => { + let rid = st.rid_of(*src); + st.handle_rid.insert(handle.0, rid); + } + Instr::Release { sym, line } => { + let rid = st.rid_of(*sym); + let s = st.states(rid); + // Python splits the message (released "twice" vs "maybe already + // 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); + } else if !state_problem(st, *sym, emit, *line) { + let (shared, mutable) = loans_on(st, *sym); + if shared > 0 || mutable { + emit.push("OWN008", *line); + } + } + st.var.insert(rid, RELEASED); + } + Instr::Use { sym, line } => { + let s = self.cfg.symbol(*sym); + match s.kind { + Kind::Owned => { + if !state_problem(st, *sym, emit, *line) { + let (_shared, mutable) = loans_on(st, *sym); + if mutable { + emit.push("OWN013", *line); + } + } + } + Kind::Borrow => { + if !self.binding_live(st, *sym) { + emit.push("OWN004", *line); + } + } + Kind::Plain => {} + } + } + Instr::Overspan { line, .. } => { + emit.push("OWN025", *line); + } + Instr::Invoke { args, line, .. } => { + for (opt_sym, eff) in args { + if let Some(sid) = opt_sym { + self.apply_effect(st, *sid, *eff, emit, *line); + } + } + } + Instr::BorrowStart { + owner, + binding, + is_mut, + line, + } => { + let kind = if *is_mut { + check_mut_borrowable(st, *owner, emit, *line); + LoanKind::Mut + } else { + check_shared_borrowable(st, *owner, emit, *line); + LoanKind::Shared + }; + let owner_rid = st.rid_of(*owner); + st.loans.insert( + binding.0, + Loan { + owner: owner_rid, + kind, + }, + ); + } + Instr::BorrowEnd { binding, .. } => { + st.loans.remove(&binding.0); + } + Instr::Return { sym, line } => { + let exclude = sym.map(|s| st.rid_of(s)); + leak_check(st, *line, emit, exclude); + if let Some(sid) = sym { + let rid = st.rid_of(*sid); + let s = st.states(rid); + if s & OWNED == 0 { + if s & MOVED != 0 { + emit.push("OWN005", *line); + } else { + emit.push("OWN002", *line); + } + } else { + let (shared, mutable) = loans_on(st, *sid); + let symbol = self.cfg.symbol(*sid); + if shared > 0 || mutable { + emit.push("OWN007", *line); + } else if let Some(buf) = &symbol.buffer { + if buf.stack_backed() { + emit.push("OWN015", *line); + } else { + emit.push("OWN017", *line); + } + } + } + st.var.insert(rid, ESCAPED); + } + } + } + } + + fn transfer_block(&self, block: usize, in_state: &State, emit: &mut Emit<'_>) -> State { + let mut st = in_state.clone(); + if let Some(blk) = self.cfg.blocks.get(block) { + for ins in &blk.instrs { + self.step(ins, &mut st, emit); + } + } + st + } +} + +impl ControlFlowGraph for Ownership<'_> { + fn num_blocks(&self) -> usize { + self.cfg.blocks.len() + } + fn entry(&self) -> usize { + self.cfg.entry.0 as usize + } + fn successors(&self, block: usize) -> &[usize] { + self.succ.get(block).map_or(&[], Vec::as_slice) + } +} + +impl Analysis for Ownership<'_> { + type Fact = StateFact; + fn entry_fact(&self) -> StateFact { + StateFact::Reachable(self.initial_state()) + } + fn transfer(&self, block: usize, in_fact: &StateFact) -> StateFact { + // Phase-1 fixpoint transfer: state evolves, no diagnostics. `Bottom` (a + // block reached before any predecessor is evaluated) transfers to + // `Bottom` — no information in, no information out; it is overwritten once + // a real predecessor arrives, so it never reaches a converged in-state. + match in_fact { + StateFact::Bottom => StateFact::Bottom, + StateFact::Reachable(st) => { + StateFact::Reachable(self.transfer_block(block, st, &mut Emit::Silent)) + } + } + } +} + +const fn instr_line(ins: &Instr) -> u32 { + match ins { + Instr::Acquire { line, .. } + | Instr::AcquireBuffer { line, .. } + | Instr::MoveInto { line, .. } + | Instr::AliasJoin { line, .. } + | Instr::Release { line, .. } + | Instr::Use { line, .. } + | Instr::Overspan { line, .. } + | Instr::Invoke { line, .. } + | Instr::BorrowStart { line, .. } + | Instr::BorrowEnd { line, .. } + | Instr::Return { line, .. } => *line, + } +} + +/// The converged concrete in-state of a graph-reachable block. +/// +/// Invariant: a reachable block always converges to `Reachable(_)` — the entry +/// seeds a concrete fact and it propagates along every reachable edge (each +/// reachable non-entry block has a reachable predecessor whose out-fact it +/// joins). A `Bottom` or missing fact here is a **solver regression**, not a real +/// empty ownership state, so this aborts loudly in EVERY build rather than +/// silently degrading to an empty state (which could mask a leak or a +/// use-after-release). `#[allow(clippy::panic)]` is the deliberate fail-loud +/// choice the checkpoint-2 review asked for. +#[allow(clippy::panic)] +fn converged_state(fact: Option<&StateFact>, bid: usize) -> State { + match fact { + Some(StateFact::Reachable(st)) => st.clone(), + Some(StateFact::Bottom) => { + panic!("reachable block {bid} remained at lattice bottom after convergence") + } + None => panic!("block {bid} was classified reachable but has no converged fact"), + } +} + +/// Run the ownership analysis over one function's CFG. +/// +/// Returns its diagnostics in Python emission order (phase-2 block transfers, +/// then end-of-function leak checks). Port of `analysis.analyze` / +/// `_Analyzer.run`. +#[must_use] +pub fn analyze(cfg: &Cfg) -> Vec { + let own = Ownership::new(cfg); + + // Phase 1: converge the in-states silently. + let solution = solve(&own, &own); + + // Reachable blocks in ascending id order (Python `sorted(reachable)`). + let reachable: Vec = (0..cfg.blocks.len()) + .filter(|&b| solution.is_reachable(b)) + .collect(); + + let mut diags: Vec = Vec::new(); + let mut out_states: BTreeMap = BTreeMap::new(); + + // 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 out = own.transfer_block(bid, &in_st, &mut emit); + out_states.insert(bid, out); + } + + // Phase 2b: leak check at every non-return function exit. + for &bid in &reachable { + let Some(blk) = cfg.blocks.get(bid) else { + continue; + }; + if !blk.succ.is_empty() { + continue; + } + if matches!(blk.instrs.last(), Some(Instr::Return { .. })) { + continue; + } + let at_line = last_line(cfg, blk); + if let Some(st) = out_states.get(&bid) { + let mut emit = Emit::Collect(&mut diags); + leak_check(st, at_line, &mut emit, None); + } + } + + diags +} + +fn last_line(cfg: &Cfg, blk: &own_cfg::Block) -> u32 { + blk.instrs + .last() + .map_or_else(|| first_line(cfg), instr_line) +} + +fn first_line(cfg: &Cfg) -> u32 { + for b in &cfg.blocks { + if let Some(first) = b.instrs.first() { + return instr_line(first); + } + } + 0 +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::{analyze, Loan, LoanKind, State, StateFact, MOVED, OWNED, RELEASED}; + use crate::solver::Lattice; + + /// A concrete state carrying one active loan (owner is OWNED), plus an + /// optional second RID's state — for exercising loan-carrying merges. + fn with_loan(binding: u32, owner: u32, kind: LoanKind, rid2: Option<(u32, u8)>) -> State { + let mut s = State::empty(); + s.var.insert(owner, OWNED); + s.loans.insert(binding, Loan { owner, kind }); + if let Some((r, bits)) = rid2 { + s.var.insert(r, bits); + } + s + } + + /// A single-RID state whose var-set is the bitmask `bits` — the finite + /// per-RID lattice `{OWNED, MOVED, RELEASED, ESCAPED}` is exactly `0..16`. + fn one(bits: u8) -> State { + let mut s = State::empty(); + s.var.insert(0, bits); + s + } + + /// The concrete data merge (two reachable predecessors). + fn joined(a: &State, b: &State) -> State { + let mut out = a.clone(); + out.join_data(b); + out + } + + #[test] + fn ownership_lattice_laws_exhaustive_per_rid() { + // Exhaustive over the finite per-RID state space (16 values), matching + // the #214 requirement: idempotent, commutative, associative join, and + // x <= join(x, y). Loans/handle_rid are empty so the merge invariant + // holds for every pair (the concrete-merge case the solver hits). + for x in 0u8..16 { + let sx = one(x); + // idempotent: join(x, x) == x, and reports no change. + let mut xx = sx.clone(); + assert!(!xx.join_data(&sx), "join(x,x) reports no change"); + assert_eq!(xx, sx, "join(x, x) == x"); + + for y in 0u8..16 { + let sy = one(y); + let xy = joined(&sx, &sy); + let yx = joined(&sy, &sx); + assert_eq!(xy, yx, "join commutative for {x},{y}"); + + // x <= join(x, y): joining x into (x∨y) does not change it. + let mut re = xy.clone(); + assert!(!re.join_data(&sx), "x <= join(x,y): {x},{y}"); + assert_eq!(re, xy); + + for z in 0u8..16 { + let sz = one(z); + let lhs = joined(&joined(&sx, &sy), &sz); + let rhs = joined(&sx, &joined(&sy, &sz)); + assert_eq!(lhs, rhs, "join associative for {x},{y},{z}"); + } + } + } + } + + #[test] + fn statefact_bottom_is_the_lattice_identity() { + // The LATTICE identity lives on StateFact, not on the concrete data. + let s = StateFact::Reachable(one(OWNED | MOVED)); + let mut a = StateFact::Bottom; + a.join(&s); + assert_eq!(a, s, "⊥ ∨ x == x"); + let mut b = s.clone(); + assert!(!b.join(&StateFact::Bottom), "x ∨ ⊥ reports no change"); + assert_eq!(b, s, "x ∨ ⊥ == x"); + } + + #[test] + fn join_reports_growth_only_when_the_set_actually_grows() { + let mut owned = one(OWNED); + let released = one(RELEASED); + // OWNED ∨ RELEASED = {OWNED, RELEASED} — a strict growth. + assert!( + owned.join_data(&released), + "adding a new state bit is a change" + ); + assert_eq!(owned.var.get(&0).copied(), Some(OWNED | RELEASED)); + // Re-joining a subset changes nothing. + assert!( + !owned.join_data(&released), + "re-join of a subset is no change" + ); + } + + #[test] + fn absent_rid_reads_as_owned_but_joins_as_empty() { + // Read default is OWNED (Python st.var.get(rid, {OWNED})). + let empty = State::empty(); + assert_eq!(empty.states(0), OWNED); + // Join default is ∅: joining a state that has rid=RELEASED with one that + // omits it yields exactly RELEASED (∅ ∪ RELEASED), NOT OWNED|RELEASED. + let mut a = State::empty(); + let mut b = State::empty(); + b.var.insert(0, RELEASED); + a.join_data(&b); + assert_eq!(a.var.get(&0).copied(), Some(RELEASED)); + } + + // ---- the explicit Bottom / concrete-empty distinction (round-2 blocker) -- + + #[test] + fn bottom_join_loan_state_is_identity() { + // ⊥ ∨ Concrete(with_loan) = Concrete(with_loan), even with active loans — + // and it must NOT run the merge invariant (bottom carries no loans to + // compare). This is what the generic solver relies on for its seed. + let x = StateFact::Reachable(with_loan(7, 1, LoanKind::Mut, Some((2, OWNED)))); + let mut b = StateFact::Bottom; + assert!(b.join(&x), "⊥ ∨ x grows from bottom"); + assert_eq!(b, x, "⊥ ∨ x == x (loans preserved)"); + + let mut xx = x.clone(); + assert!(!xx.join(&StateFact::Bottom), "x ∨ ⊥ is no change"); + assert_eq!(xx, x, "x ∨ ⊥ == x"); + } + + #[test] + #[should_panic(expected = "active loans differ")] + fn concrete_empty_join_loan_state_fails_loud() { + // Concrete(empty) ∨ Concrete(with_loan): a real empty predecessor is NOT + // bottom, so the merge invariant runs and fails on {} vs {loan}. + let empty = StateFact::Reachable(State::empty()); + let loaned = StateFact::Reachable(with_loan(7, 1, LoanKind::Shared, None)); + let mut a = empty; + let _ = a.join(&loaned); + } + + #[test] + #[should_panic(expected = "active loans differ")] + fn loan_state_join_concrete_empty_fails_loud() { + // The symmetric direction: Concrete(with_loan) ∨ Concrete(empty) also + // fails loudly — the invariant is not order-dependently bypassable. + let empty = StateFact::Reachable(State::empty()); + let mut loaned = StateFact::Reachable(with_loan(7, 1, LoanKind::Shared, None)); + let _ = loaned.join(&empty); + } + + #[test] + fn concrete_empty_is_not_lattice_bottom() { + // The load-bearing distinction: a reachable empty state is a distinct + // value from ⊥ (so structural emptiness never masquerades as bottom). + assert_ne!( + StateFact::Reachable(State::empty()), + StateFact::Bottom, + "Concrete(empty) must not equal lattice Bottom" + ); + assert_eq!(::bottom(), StateFact::Bottom); + } + + #[test] + fn compatible_loan_merge_is_commutative_and_associative() { + // Three states sharing the SAME loan (binding 7 → owner 1, Shared) but + // differing on a second RID — the shape of a loan held across a merge. + let mk = |bits2: u8| with_loan(7, 1, LoanKind::Shared, Some((2, bits2))); + let a = mk(OWNED); + let b = mk(MOVED); + let c = mk(RELEASED); + + assert_eq!( + joined(&a, &b), + joined(&b, &a), + "commutative with equal loans" + ); + let lhs = joined(&joined(&a, &b), &c); + let rhs = joined(&a, &joined(&b, &c)); + assert_eq!(lhs, rhs, "associative with equal loans"); + + // x <= join(x, y) + let ab = joined(&a, &b); + let mut re = ab.clone(); + assert!(!re.join_data(&a), "a <= join(a, b)"); + assert_eq!(re, ab); + // The shared loan survives the merge unchanged. + assert_eq!(ab.loans.get(&7).map(|l| l.kind), Some(LoanKind::Shared)); + } + + #[test] + #[should_panic(expected = "active loans differ")] + fn same_binding_different_owner_or_kind_fails_loud() { + // Two real states carry binding 7 with a DIFFERENT owner AND kind — the + // full-value invariant must fail loudly (key-equality alone would miss it). + let a = with_loan(7, 1, LoanKind::Shared, None); + let b = with_loan(7, 2, LoanKind::Mut, None); + let _ = joined(&a, &b); + } + + #[test] + fn ownership_fixpoint_is_schedule_independent() { + use super::Ownership; + use crate::solver::{solve_with, Schedule}; + + // A loop with an internal branch over a real resource — the fixpoint + // must converge to identical per-block in-states under every schedule + // (the State lattice, not just the toy TokenSet). + let src = "module M\n\ + resource Conn { acquire open release close }\n\ + extern fn Hash(borrow Conn);\n\ + fn f(n: int) {\n\ + let c = acquire Conn(1);\n\ + while (n) {\n\ + if (n) { Hash(c); }\n\ + Hash(c);\n\ + }\n\ + release c;\n\ + return;\n\ + }\n"; + let module = own_syntax::parse(src).expect("parses"); + let (cfgs, _d1) = own_cfg::build_module(&module); + let cfg = cfgs.first().expect("one function"); + let own = Ownership::new(cfg); + + let schedules = [ + Schedule::Rpo, + Schedule::Postorder, + Schedule::Fifo, + Schedule::Lifo, + Schedule::BlockOrder, + ]; + let reference: Vec> = { + let sol = solve_with(&own, &own, Schedule::Rpo); + (0..cfg.blocks.len()) + .map(|b| sol.in_fact(b).cloned()) + .collect() + }; + for sched in schedules { + let sol = solve_with(&own, &own, sched); + let got: Vec> = (0..cfg.blocks.len()) + .map(|b| sol.in_fact(b).cloned()) + .collect(); + assert_eq!( + got, reference, + "ownership fixpoint diverged under schedule {sched:?}" + ); + } + // And the emitting analysis (which uses the default schedule) is clean. + assert!(analyze(cfg).is_empty(), "the loop program is well-formed"); + } + + #[test] + fn every_reachable_block_converges_to_a_reachable_fact() { + use super::Ownership; + use crate::solver::solve; + + // The invariant `converged_state` relies on: after convergence, every + // graph-reachable block has a `Reachable(_)` fact — never `Bottom`. A + // program with a loop, a branch and multiple exits exercises the worklist. + let src = "module M\n\ + resource Conn { acquire open release close }\n\ + extern fn Hash(borrow Conn);\n\ + fn f(n: int) {\n\ + let c = acquire Conn(1);\n\ + if (n) {\n\ + Hash(c);\n\ + release c;\n\ + return;\n\ + }\n\ + while (n) { Hash(c); }\n\ + release c;\n\ + return;\n\ + }\n"; + let module = own_syntax::parse(src).expect("parses"); + let (cfgs, _d1) = own_cfg::build_module(&module); + let cfg = cfgs.first().expect("one function"); + let own = Ownership::new(cfg); + let sol = solve(&own, &own); + + let mut reachable = 0; + for b in 0..cfg.blocks.len() { + if sol.is_reachable(b) { + reachable += 1; + assert!( + matches!(sol.in_fact(b), Some(StateFact::Reachable(_))), + "reachable block {b} converged to a non-Reachable fact: {:?}", + sol.in_fact(b) + ); + } else { + // An unreachable block must carry no fact at all. + assert!(sol.in_fact(b).is_none(), "unreachable block {b} has a fact"); + } + } + assert!( + reachable >= 4, + "the program should have several reachable blocks" + ); + } + + #[test] + fn loan_active_across_a_branch_merge_analyzes_cleanly() { + // A real CFG where a mutable borrow spans an internal `if`, so the loan + // is live on BOTH predecessors of the merge inside the borrow block. + // Python reports no diagnostics; the Rust merge must join two loan- + // carrying states (equal loans) without tripping the invariant. + let src = "module M\n\ + resource Conn { acquire open release close }\n\ + extern fn Fill(borrow_mut Conn);\n\ + fn f(n: int) {\n\ + let c = acquire Conn(1);\n\ + borrow_mut c as m {\n\ + if (n) { Fill(m); }\n\ + Fill(m);\n\ + }\n\ + release c;\n\ + return;\n\ + }\n"; + let module = own_syntax::parse(src).expect("parses"); + let (cfgs, _d1) = own_cfg::build_module(&module); + let cfg = cfgs.first().expect("one function"); + let diags = analyze(cfg); + assert!( + diags.is_empty(), + "a well-formed borrow spanning a branch is clean, got {:?}", + diags.iter().map(|d| (d.line, &d.code)).collect::>() + ); + } + + #[test] + fn state_serialization_is_not_required_but_var_map_is_deterministic() { + // A BTreeMap iterates in key order, so two equal states compare equal and + // hash-independent — the property the fixpoint's `!=` convergence relies on. + let mut a = State::empty(); + a.var.insert(2, OWNED); + a.var.insert(1, RELEASED); + let mut b = State::empty(); + b.var.insert(1, RELEASED); + b.var.insert(2, OWNED); + assert_eq!(a, b, "insertion order must not affect State equality"); + let keys: Vec = a.var.keys().copied().collect(); + assert_eq!(keys, vec![1, 2], "BTreeMap keeps deterministic key order"); + } +} diff --git a/rust/crates/own-analysis/src/solver.rs b/rust/crates/own-analysis/src/solver.rs new file mode 100644 index 00000000..67cbf6bb --- /dev/null +++ b/rust/crates/own-analysis/src/solver.rs @@ -0,0 +1,366 @@ +//! A generic monotone-lattice forward dataflow solver (P-022 §"Solver +//! scheduling"; the `rustc_mir_dataflow` shape the Python `_Analyzer.fixpoint` +//! ports to). +//! +//! The contract: +//! +//! * [`Lattice`] — a join-semilattice with a `bottom`. `join` is the least upper +//! bound *into self*, returning whether `self` changed. Callers guarantee it is +//! idempotent, commutative, associative and monotone; `tests/solver.rs` checks +//! those laws exhaustively for the concrete lattices. +//! * [`ControlFlowGraph`] — blocks `0..num_blocks`, an entry, and forward +//! successor edges. Predecessors and reachability are derived here. +//! * [`Analysis`] — the boundary `entry_fact` and a monotone `transfer`. +//! +//! [`solve`] returns the converged **in-fact** per reachable block. Because the +//! join is commutative/associative and the transfer monotone, the fixpoint is +//! **unique regardless of visitation order** — [`Schedule`] only changes how many +//! times blocks are re-visited, never the result (proven in +//! `tests/solver.rs::worklist_order_independence`). Reverse-postorder is the +//! default because it minimizes re-visits on reducible CFGs. +//! +//! A **convergence guard** counts block visits and fails loudly (`assert!`) if it +//! exceeds a generous bound — a non-monotone `join` or an ever-ascending lattice +//! would otherwise spin forever. Monotone analyses over a finite lattice never +//! approach it. + +use std::collections::{BTreeSet, VecDeque}; + +/// A join-semilattice element with a least (`bottom`) value. +pub trait Lattice: Clone + PartialEq { + /// The identity for [`join`](Lattice::join): `x.join(bottom) == x` and + /// `bottom` is `<=` every element. + fn bottom() -> Self; + + /// Join `other` into `self` (least upper bound), returning `true` iff `self` + /// changed. Must be idempotent, commutative, associative and monotone. + fn join(&mut self, other: &Self) -> bool; +} + +/// A forward control-flow graph over blocks `0..num_blocks()`. +pub trait ControlFlowGraph { + /// Number of blocks; ids are `0..num_blocks`. + fn num_blocks(&self) -> usize; + /// The entry block id. + fn entry(&self) -> usize; + /// Forward successor block ids of `block`. + fn successors(&self, block: usize) -> &[usize]; +} + +/// A monotone forward dataflow analysis over a [`ControlFlowGraph`]. +pub trait Analysis { + /// The lattice of dataflow facts. + type Fact: Lattice; + /// The boundary fact flowing into the entry block. + fn entry_fact(&self) -> Self::Fact; + /// The block's out-fact given its in-fact. Must be monotone in `in_fact`. + fn transfer(&self, block: usize, in_fact: &Self::Fact) -> Self::Fact; +} + +/// Worklist visitation order. +/// +/// The converged solution is identical for every variant (that is the point of +/// `worklist_order_independence`); only the visit count differs. +/// [`Schedule::Rpo`] is the efficient default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Schedule { + /// Reverse postorder — a block after its (non-back-edge) predecessors. + Rpo, + /// Postorder — the RPO reversed (a deliberately poor order, for the test). + Postorder, + /// FIFO queue. + Fifo, + /// LIFO stack. + Lifo, + /// Ascending block id. + BlockOrder, +} + +/// The converged analysis result: the in-fact of every reachable block +/// (`None` for unreachable blocks). +#[derive(Debug, Clone)] +pub struct Solution { + in_facts: Vec>, +} + +impl Solution { + /// The converged in-fact of `block`, or `None` if the block is unreachable. + #[must_use] + pub fn in_fact(&self, block: usize) -> Option<&F> { + self.in_facts.get(block).and_then(Option::as_ref) + } + + /// Whether `block` is reachable from the entry. + #[must_use] + pub fn is_reachable(&self, block: usize) -> bool { + self.in_facts.get(block).is_some_and(Option::is_some) + } +} + +/// Solve `analysis` over `graph` with the default reverse-postorder schedule. +#[must_use] +pub fn solve(graph: &G, analysis: &A) -> Solution +where + G: ControlFlowGraph, + A: Analysis, +{ + solve_with(graph, analysis, Schedule::Rpo) +} + +/// Solve with an explicit [`Schedule`]. The result is schedule-independent; this +/// entry point exists so the order-independence property can be exercised. +/// +/// # Panics +/// The convergence guard `assert!`s (fails loudly) if block visits exceed a +/// generous bound — which only happens if `A::Fact`'s `join` is non-monotone or +/// non-idempotent, i.e. violates the [`Lattice`] contract. A conforming monotone +/// analysis over a finite lattice never approaches it. +#[must_use] +pub fn solve_with(graph: &G, analysis: &A, schedule: Schedule) -> Solution +where + G: ControlFlowGraph, + A: Analysis, +{ + let n = graph.num_blocks(); + let entry = graph.entry(); + + let preds = predecessors(graph); + let reachable = reachable_from(graph, entry); + let rpo_index = rpo_indices(graph, entry, &reachable); + + // out[b] holds the last computed out-fact; None until first visited. + let mut out: Vec> = vec![None; n]; + let mut in_facts: Vec> = vec![None; n]; + + // Seed every reachable block so unreachable ones are never touched. The seed + // order is ascending block id; the block-ID-permutation test proves the + // converged result does not depend on it. + let mut worklist = Worklist::new(schedule, n, &rpo_index); + for &b in &reachable { + worklist.push(b); + } + + // Convergence guard: a monotone analysis over a finite lattice re-visits each + // block a bounded number of times. This backstop catches a non-monotone join + // (an infinite ascent) and fails loudly instead of hanging. + let reachable_len = reachable.len(); + let visit_cap = reachable_len + .saturating_mul(reachable_len) + .saturating_mul(64) + .saturating_add(1024); + let mut visits: usize = 0; + + while let Some(b) = worklist.pop() { + visits = visits.saturating_add(1); + assert!( + visits <= visit_cap, + "dataflow did not converge after {visits} block visits (cap {visit_cap}); \ + a non-monotone or non-idempotent `join` is the usual cause" + ); + + // in[b] = entry boundary if b is the entry, else the join of predecessor + // out-facts. The entry's boundary fact dominates even under a back-edge. + // + // The merge is **seeded from the first predecessor with an out-fact** and + // joins the rest — never `bottom.join(pred)`. This matches Python's + // `in_state_of` (`out[ps[0]].copy()` then join `ps[1:]`) and, crucially, + // means `join` is only ever called between two *real* predecessor states, + // so a domain whose `join` carries a merge invariant (e.g. ownership's + // block-scoped-loan equality) is never handed the empty `bottom` on one + // side. With no predecessor yet processed, the seed is `bottom` (used + // alone, unjoined) — the transient first-visit value, exactly like + // Python's `State()` fallback. + let in_b = if b == entry { + analysis.entry_fact() + } else { + let mut acc: Option = None; + if let Some(ps) = preds.get(b) { + for &p in ps { + if let Some(Some(op)) = out.get(p) { + match acc.as_mut() { + None => acc = Some(op.clone()), + Some(a) => { + a.join(op); + } + } + } + } + } + acc.unwrap_or_else(A::Fact::bottom) + }; + + let out_b = analysis.transfer(b, &in_b); + if let Some(slot) = in_facts.get_mut(b) { + *slot = Some(in_b); + } + + let changed = match out.get(b) { + Some(Some(prev)) => prev != &out_b, + _ => true, + }; + if changed { + if let Some(slot) = out.get_mut(b) { + *slot = Some(out_b); + } + for &s in graph.successors(b) { + if reachable.contains(&s) { + worklist.push(s); + } + } + } + } + + Solution { in_facts } +} + +/// The worklist backing store — a *materially distinct* container per schedule, +/// with a `queued` bitset so a block is never enqueued twice. +enum Queue { + /// Rpo / Postorder / `BlockOrder` — pop the smallest precomputed key. + Priority(BTreeSet<(usize, usize)>), + /// True FIFO: `push_back` / `pop_front` (visit in first-enqueued order). + Fifo(VecDeque), + /// True LIFO: `push` / `pop` (visit the most-recently-enqueued first). + Lifo(Vec), +} + +struct Worklist<'r> { + queue: Queue, + queued: Vec, + schedule: Schedule, + rpo_index: &'r [usize], + n: usize, +} + +impl<'r> Worklist<'r> { + fn new(schedule: Schedule, n: usize, rpo_index: &'r [usize]) -> Self { + let queue = match schedule { + Schedule::Fifo => Queue::Fifo(VecDeque::new()), + Schedule::Lifo => Queue::Lifo(Vec::new()), + Schedule::Rpo | Schedule::Postorder | Schedule::BlockOrder => { + Queue::Priority(BTreeSet::new()) + } + }; + Self { + queue, + queued: vec![false; n], + schedule, + rpo_index, + n, + } + } + + /// The priority key for the `Priority` schedules (unused by Fifo/Lifo). + fn key(&self, block: usize) -> usize { + let rpo = self.rpo_index.get(block).copied().unwrap_or(self.n); + match self.schedule { + Schedule::Rpo => rpo, // smallest RPO first + Schedule::Postorder => self.n.saturating_sub(rpo), // largest RPO first + // BlockOrder pops smallest id; Fifo/Lifo don't consult the key. + Schedule::BlockOrder | Schedule::Fifo | Schedule::Lifo => block, + } + } + + fn push(&mut self, block: usize) { + if self.queued.get(block).copied() == Some(true) { + return; + } + if let Some(q) = self.queued.get_mut(block) { + *q = true; + } + let key = self.key(block); + match &mut self.queue { + Queue::Priority(s) => { + s.insert((key, block)); + } + Queue::Fifo(q) => q.push_back(block), + Queue::Lifo(v) => v.push(block), + } + } + + fn pop(&mut self) -> Option { + let block = match &mut self.queue { + Queue::Priority(s) => s.pop_first().map(|(_, b)| b), + Queue::Fifo(q) => q.pop_front(), + Queue::Lifo(v) => v.pop(), + }?; + if let Some(q) = self.queued.get_mut(block) { + *q = false; + } + Some(block) + } +} + +fn predecessors(graph: &G) -> Vec> { + let n = graph.num_blocks(); + let mut preds = vec![Vec::new(); n]; + for b in 0..n { + for &s in graph.successors(b) { + if let Some(p) = preds.get_mut(s) { + p.push(b); + } + } + } + preds +} + +fn reachable_from(graph: &G, entry: usize) -> BTreeSet { + let mut seen = BTreeSet::new(); + let mut stack = vec![entry]; + while let Some(b) = stack.pop() { + if seen.insert(b) { + for &s in graph.successors(b) { + if !seen.contains(&s) { + stack.push(s); + } + } + } + } + seen +} + +/// Reverse-postorder index for each block (`rpo_index[b]` smaller ⇒ earlier). +/// Unreachable blocks get index `n` (sorted last, never actually processed). +fn rpo_indices( + graph: &G, + entry: usize, + reachable: &BTreeSet, +) -> Vec { + let n = graph.num_blocks(); + // Iterative postorder DFS (explicit stack of (block, next-successor-index)). + let mut postorder: Vec = Vec::new(); + let mut visited = vec![false; n]; + let mut stack: Vec<(usize, usize)> = vec![(entry, 0)]; + if let Some(v) = visited.get_mut(entry) { + *v = true; + } + while let Some(&(b, i)) = stack.last() { + let succ = graph.successors(b); + if let Some(&s) = succ.get(i) { + if let Some(top) = stack.last_mut() { + top.1 = i.saturating_add(1); + } + if visited.get(s).copied() == Some(false) { + if let Some(v) = visited.get_mut(s) { + *v = true; + } + stack.push((s, 0)); + } + } else { + postorder.push(b); + stack.pop(); + } + } + // RPO = reverse of postorder; assign ascending indices. + let mut index = vec![n; n]; + let mut next = 0usize; + for &b in postorder.iter().rev() { + if reachable.contains(&b) { + if let Some(slot) = index.get_mut(b) { + *slot = next; + } + next = next.saturating_add(1); + } + } + index +} diff --git a/rust/crates/own-analysis/tests/diff_gen.rs b/rust/crates/own-analysis/tests/diff_gen.rs new file mode 100644 index 00000000..6ecf0c3e --- /dev/null +++ b/rust/crates/own-analysis/tests/diff_gen.rs @@ -0,0 +1,106 @@ +//! Generated differential battery — the Rust side of the seeded mini-program +//! oracle (#214). Replays `tests/fixtures/diag_diff_gen.json` (authoritative: +//! `python tests/test_diff_gen_fixtures.py --write`) through the ported `check` +//! surface with **zero Python**. On any divergence it prints the failing seed + +//! source so the case can be lifted into a permanent regression fixture. +//! +//! Same sound covered/deferred partition as `parity.rs`: a case is asserted only +//! when none of its Python codes come from a not-yet-ported pass (the generator +//! is tuned to emit only ownership faults, so in practice every case is covered). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::Value; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/diag_diff_gen.json" +); + +fn is_fact_only(code: &str) -> bool { + code.starts_with("DI") || code.starts_with("EFF") || code.starts_with("OBL") +} + +/// The full Rust `check` surface (parse → `check_module`; parse error → OWN020). +fn rust_check(source: &str) -> Vec<(u32, String)> { + match own_syntax::parse(source) { + Err(e) => { + let line = match e { + own_syntax::SyntaxError::Lex(le) => le.line, + own_syntax::SyntaxError::Parse(pe) => pe.line, + }; + vec![(line, "OWN020".to_owned())] + } + Ok(module) => own_analysis::check_module(&module) + .into_iter() + .map(|d| (d.line, d.code)) + .collect(), + } +} + +fn golden(case: &Value) -> Vec<(u32, String)> { + case.get("diags") + .and_then(Value::as_array) + .expect("case 'diags' array") + .iter() + .map(|pair| { + let pair = pair.as_array().expect("[line, code]"); + let line = u32::try_from(pair.first().and_then(Value::as_u64).expect("line")) + .expect("line fits u32"); + let code = pair + .get(1) + .and_then(Value::as_str) + .expect("code") + .to_owned(); + (line, code) + }) + .collect() +} + +#[test] +fn generated_ownership_programs_match_python() { + let raw = std::fs::read_to_string(FIXTURE) + .expect("fixture missing — regenerate: python tests/test_diff_gen_fixtures.py --write"); + let root: Value = serde_json::from_str(&raw).expect("diag_diff_gen.json parses"); + let cases = root + .get("cases") + .and_then(Value::as_array) + .expect("'cases' array"); + assert!(cases.len() >= 100, "generated corpus must be substantial"); + + let mut covered = 0usize; + let mut deferred = 0usize; + let mut failures: Vec = Vec::new(); + + for case in cases { + let seed = case.get("seed").and_then(Value::as_u64).expect("seed"); + let source = case.get("source").and_then(Value::as_str).expect("source"); + let py = golden(case); + + if py.iter().any(|(_, c)| is_fact_only(c)) { + deferred += 1; + continue; + } + covered += 1; + + let got = rust_check(source); + if got != py { + // Print the seed + source so this becomes a permanent regression. + failures.push(format!( + "SEED {seed} diverged:\n--- source ---\n{source}--- python {py:?}\n--- rust {got:?}" + )); + } + } + + eprintln!("generated differential: {covered} covered asserted, {deferred} deferred"); + assert!( + failures.is_empty(), + "{} generated-program divergence(s):\n{}", + failures.len(), + failures.join("\n\n") + ); + assert!( + covered >= 100, + "expected >= 100 covered generated cases, got {covered}" + ); +} diff --git a/rust/crates/own-analysis/tests/fact_parity.rs b/rust/crates/own-analysis/tests/fact_parity.rs new file mode 100644 index 00000000..3df4e693 --- /dev/null +++ b/rust/crates/own-analysis/tests/fact_parity.rs @@ -0,0 +1,209 @@ +//! Fact-level differential parity for the effect (EFF001) and DI (DI001–005) +//! analyses — the Rust side of the Python-authored oracle +//! (`tests/fixtures/di_eff_fact_parity.json`, regenerate: +//! `python tests/test_di_eff_fact_parity.py --write`). +//! +//! These families have no `.own` surface; Python is the reference. This test +//! deserializes the frozen fact inputs, runs the ported analyses, and asserts the +//! exact ordered `(path, line, code)` verdict list — with **zero Python**. On any +//! divergence it prints the case name and both lists. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use own_analysis::di::{self, Lifetime, Service}; +use own_analysis::effect::{Binding, Effect}; +use serde_json::Value; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/di_eff_fact_parity.json" +); + +fn strs(v: Option<&Value>) -> Vec { + v.and_then(Value::as_array).map_or_else(Vec::new, |a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_owned)) + .collect() + }) +} + +fn u32_of(v: Option<&Value>) -> u32 { + v.and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(0) +} + +fn sites(v: Option<&Value>) -> Vec<(String, String, u32)> { + v.and_then(Value::as_array).map_or_else(Vec::new, |a| { + a.iter() + .filter_map(|t| { + let t = t.as_array()?; + Some(( + t.first()?.as_str()?.to_owned(), + t.get(1)?.as_str()?.to_owned(), + u32::try_from(t.get(2)?.as_u64()?).ok()?, + )) + }) + .collect() + }) +} + +fn effect_from(v: &Value) -> Effect { + let bindings = v + .get("bindings") + .and_then(Value::as_array) + .map_or_else(Vec::new, |a| { + a.iter() + .map(|b| Binding { + name: b + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(), + init: b + .get("init") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(), + refs: strs(b.get("refs")), + line: u32_of(b.get("line")), + }) + .collect() + }); + Effect { + component: v + .get("component") + .and_then(Value::as_str) + .unwrap_or("?") + .to_owned(), + deps: strs(v.get("deps")), + io: v.get("io").and_then(Value::as_bool).unwrap_or(false), + bindings, + file: v + .get("file") + .and_then(Value::as_str) + .unwrap_or("?") + .to_owned(), + line: u32_of(v.get("line")), + } +} + +fn service_from(v: &Value) -> Service { + let lifetime = v + .get("lifetime") + .and_then(Value::as_str) + .and_then(Lifetime::parse); + Service { + name: v + .get("name") + .and_then(Value::as_str) + .unwrap_or("?") + .to_owned(), + lifetime, + deps: strs(v.get("deps")), + disposable: v + .get("disposable") + .and_then(Value::as_bool) + .unwrap_or(false), + file: v + .get("file") + .and_then(Value::as_str) + .unwrap_or("?") + .to_owned(), + line: u32_of(v.get("line")), + weak_deps: strs(v.get("weak_deps")), + root_resolves: strs(v.get("root_resolves")), + root_resolve_sites: sites(v.get("root_resolve_sites")), + scope_cached: strs(v.get("scope_cached")), + scope_cache_sites: sites(v.get("scope_cache_sites")), + } +} + +fn expected(case: &Value) -> Vec<(String, u32, String)> { + case.get("expected") + .and_then(Value::as_array) + .expect("'expected'") + .iter() + .map(|row| { + let row = row.as_array().expect("[file, line, code]"); + ( + row.first() + .and_then(Value::as_str) + .expect("file") + .to_owned(), + u32_of(row.get(1)), + row.get(2).and_then(Value::as_str).expect("code").to_owned(), + ) + }) + .collect() +} + +fn load() -> Value { + let raw = std::fs::read_to_string(FIXTURE) + .expect("fixture missing — regenerate: python tests/test_di_eff_fact_parity.py --write"); + serde_json::from_str(&raw).expect("di_eff_fact_parity.json parses") +} + +#[test] +fn effect_fact_parity() { + let root = load(); + let cases = root + .get("effect_cases") + .and_then(Value::as_array) + .expect("'effect_cases'"); + let mut failures = Vec::new(); + for case in cases { + let name = case.get("name").and_then(Value::as_str).expect("name"); + let effects: Vec = case + .get("effects") + .and_then(Value::as_array) + .expect("effects") + .iter() + .map(effect_from) + .collect(); + let got: Vec<(String, u32, String)> = own_analysis::effect_verdicts(&effects) + .into_iter() + .map(|(f, l, c)| (f, l, c.to_owned())) + .collect(); + let want = expected(case); + if got != want { + failures.push(format!( + "effect case {name}:\n python={want:?}\n rust ={got:?}" + )); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + assert!(cases.len() >= 8, "expected the full effect corpus"); +} + +#[test] +fn di_fact_parity() { + let root = load(); + let cases = root + .get("di_cases") + .and_then(Value::as_array) + .expect("'di_cases'"); + let mut failures = Vec::new(); + for case in cases { + let name = case.get("name").and_then(Value::as_str).expect("name"); + let services: Vec = case + .get("services") + .and_then(Value::as_array) + .expect("services") + .iter() + .map(service_from) + .collect(); + let got: Vec<(String, u32, String)> = di::di_verdicts(&services) + .into_iter() + .map(|(f, l, c)| (f, l, c.to_owned())) + .collect(); + let want = expected(case); + if got != want { + failures.push(format!( + "DI case {name}:\n python={want:?}\n rust ={got:?}" + )); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + assert!(cases.len() >= 13, "expected the full DI corpus"); +} diff --git a/rust/crates/own-analysis/tests/metamorphic.rs b/rust/crates/own-analysis/tests/metamorphic.rs new file mode 100644 index 00000000..653eda87 --- /dev/null +++ b/rust/crates/own-analysis/tests/metamorphic.rs @@ -0,0 +1,100 @@ +//! Metamorphic properties for the ownership analysis (#214): transformations +//! that must not change the diagnostic meaning, checked without Python (steady- +//! state Rust tests invoke no Python). +//! +//! * renaming a local does not alter the `(line, code)` set; +//! * repeated analysis is identical (determinism / idempotence); +//! * adding unreachable code never removes an existing finding; +//! * a diagnostic survives a serde round-trip unchanged (result serialization). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use own_diagnostics::Diagnostic; + +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"; + +/// Analyze every function in `source`, returning the flat diagnostic list. +fn analyze_all(source: &str) -> Vec { + let module = own_syntax::parse(source).expect("test source parses"); + let (cfgs, _d1) = own_cfg::build_module(&module); + let mut out = Vec::new(); + for cfg in &cfgs { + out.extend(own_analysis::analyze(cfg)); + } + out +} + +fn keys(diags: &[Diagnostic]) -> Vec<(u32, String)> { + let mut v: Vec<(u32, String)> = diags.iter().map(|d| (d.line, d.code.clone())).collect(); + v.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + v +} + +#[test] +fn renaming_a_local_preserves_diagnostics() { + // A use-after-release; the owned local is named `c` in one, `resourceHandle` + // in the other. Same lines (rename is in-place), so `(line, code)` is equal. + let a = format!( + "{PRELUDE}fn f() {{\n let c = acquire Conn(1);\n release c;\n Hash(c);\n return;\n}}\n" + ); + let b = format!( + "{PRELUDE}fn f() {{\n let resourceHandle = acquire Conn(1);\n release resourceHandle;\n Hash(resourceHandle);\n return;\n}}\n" + ); + let ka = keys(&analyze_all(&a)); + let kb = keys(&analyze_all(&b)); + assert_eq!(ka, kb, "a local rename must not change the diagnostic set"); + assert!( + ka.iter().any(|(_, c)| c == "OWN002"), + "sanity: the use-after-release is actually flagged" + ); +} + +#[test] +fn repeated_analysis_is_identical() { + let src = format!("{PRELUDE}fn f() {{\n let c = acquire Conn(1);\n return;\n}}\n"); + let first = keys(&analyze_all(&src)); + let second = keys(&analyze_all(&src)); + assert_eq!(first, second, "analysis must be deterministic / idempotent"); + assert!( + first.iter().any(|(_, c)| c == "OWN001"), + "sanity: the leak is flagged (once, deterministically): {first:?}" + ); +} + +#[test] +fn adding_unreachable_code_never_removes_a_finding() { + let base = format!("{PRELUDE}fn f() {{\n let c = acquire Conn(1);\n return;\n}}\n"); + let base_keys = keys(&analyze_all(&base)); + assert!(!base_keys.is_empty(), "base has a leak to preserve"); + + // Append an entire additional (independently analyzed) function AND a + // statement after a return. The original leak at line 4 must remain. + let extended = format!( + "{base}fn g() {{\n let d = acquire Conn(2);\n release d;\n return;\n Store(d);\n}}\n" + ); + let ext_keys = keys(&analyze_all(&extended)); + for k in &base_keys { + assert!( + ext_keys.contains(k), + "adding code removed the pre-existing finding {k:?}" + ); + } +} + +#[test] +fn diagnostics_survive_a_serde_round_trip() { + let src = format!( + "{PRELUDE}fn f() {{\n let c = acquire Conn(1);\n release c;\n release c;\n return;\n}}\n" + ); + let diags = analyze_all(&src); + assert!(!diags.is_empty()); + let json = serde_json::to_string(&diags).expect("diagnostics serialize"); + let back: Vec = serde_json::from_str(&json).expect("round-trip deserializes"); + assert_eq!( + diags, back, + "serialization round-trip must preserve results" + ); +} diff --git a/rust/crates/own-analysis/tests/parity.rs b/rust/crates/own-analysis/tests/parity.rs new file mode 100644 index 00000000..99c93f7b --- /dev/null +++ b/rust/crates/own-analysis/tests/parity.rs @@ -0,0 +1,115 @@ +//! Full diagnostics parity — the Rust side of the #214 oracle at **checkpoint +//! 3**. Replays the frozen `tests/fixtures/diag_parity.json` (authoritative: +//! `python tests/test_diag_fixtures.py --write`) through the complete ported +//! `check` surface (`own_analysis::check_module`: buffer policy + lifetime + +//! resolver + ownership) and asserts `(line, code)` equality, in emission order, +//! for **every** case — no partition, no deferral (the `.own` corpus contains no +//! OwnIR-fact-only families; DI*/EFF*/OBL* land with `own-bridge`, step 6). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::Value; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/diag_parity.json" +); + +/// OwnIR-fact sidecar families (own-bridge, step 6) that no `.own` input can +/// exercise. If one ever appears in the corpus, flag it loudly rather than +/// silently pass — it needs the fact surface, not this seam. +fn is_fact_only(code: &str) -> bool { + code.starts_with("DI") || code.starts_with("EFF") || code.starts_with("OBL") +} + +/// The full Rust `check` surface: parse (own-syntax) → on error a synthetic +/// OWN020 at the error line (the preserved Python quirk) → else the complete +/// `check_module` composition, already sorted by `(line, code)`. +fn rust_check(source: &str) -> Vec<(u32, String)> { + match own_syntax::parse(source) { + Err(e) => { + // `_collect` wraps a lex/parse failure as one OWN020 at the error line. + let line = match e { + own_syntax::SyntaxError::Lex(le) => le.line, + own_syntax::SyntaxError::Parse(pe) => pe.line, + }; + vec![(line, "OWN020".to_owned())] + } + Ok(module) => own_analysis::check_module(&module) + .into_iter() + .map(|d| (d.line, d.code)) + .collect(), + } +} + +fn python_diags(case: &Value) -> Vec<(u32, String)> { + case.get("diags") + .and_then(Value::as_array) + .expect("case 'diags' array") + .iter() + .map(|pair| { + let pair = pair.as_array().expect("[line, code]"); + let line = u32::try_from(pair.first().and_then(Value::as_u64).expect("line")) + .expect("line fits u32"); + let code = pair + .get(1) + .and_then(Value::as_str) + .expect("code") + .to_owned(); + (line, code) + }) + .collect() +} + +#[test] +fn full_parity_on_the_frozen_corpus() { + let raw = std::fs::read_to_string(FIXTURE) + .expect("fixture missing — regenerate: python tests/test_diag_fixtures.py --write"); + let root: Value = serde_json::from_str(&raw).expect("diag_parity.json parses"); + let cases = root + .get("cases") + .and_then(Value::as_array) + .expect("'cases' array"); + + let mut asserted = 0usize; + let mut fact_only: Vec = Vec::new(); + let mut failures: Vec = Vec::new(); + + for case in cases { + let name = case.get("name").and_then(Value::as_str).expect("name"); + let source = case.get("source").and_then(Value::as_str).expect("source"); + let py = python_diags(case); + + // The whole `.own` surface is ported now; only OwnIR-fact families would + // be out of scope, and the corpus has none — record any as a loud signal. + if py.iter().any(|(_, c)| is_fact_only(c)) { + fact_only.push(name.to_owned()); + continue; + } + asserted += 1; + + let got = rust_check(source); + if got != py { + failures.push(format!( + "case {name}:\n python = {py:?}\n rust = {got:?}" + )); + } + } + + eprintln!( + "full parity: {asserted} cases asserted, {} fact-only skipped", + fact_only.len() + ); + assert!( + fact_only.is_empty(), + "the .own corpus grew a fact-only case (needs own-bridge): {fact_only:?}" + ); + assert!( + failures.is_empty(), + "{} full-parity divergence(s):\n{}", + failures.len(), + failures.join("\n") + ); + // Guard against a silently-shrunk corpus. + assert!(asserted >= 65, "expected the full corpus, got {asserted}"); +} diff --git a/rust/crates/own-analysis/tests/solver.rs b/rust/crates/own-analysis/tests/solver.rs new file mode 100644 index 00000000..8dd1d9b6 --- /dev/null +++ b/rust/crates/own-analysis/tests/solver.rs @@ -0,0 +1,511 @@ +//! Isolated tests for the generic worklist solver — the mandated correctness +//! battery from #214, exercised on a simple concrete lattice *before* any domain +//! rule is ported: +//! +//! * CFG shapes: straight line, diamond, loop, nested loop, unreachable block, +//! multiple predecessors, an abnormal (early) exit; +//! * lattice laws (idempotent / commutative / associative join, `x <= join`), +//! **exhaustively** over a small state space; +//! * worklist-order independence across every [`Schedule`]; +//! * the convergence guard fires on a deliberately non-monotone join. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use own_analysis::{solve, solve_with, Analysis, ControlFlowGraph, Lattice, Schedule}; +use std::cell::RefCell; +use std::collections::BTreeSet; + +// ---- a tiny concrete lattice: a set of u8 tokens under union ---------------- + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TokenSet(BTreeSet); + +impl TokenSet { + fn of(items: &[u8]) -> Self { + Self(items.iter().copied().collect()) + } +} + +impl Lattice for TokenSet { + fn bottom() -> Self { + Self(BTreeSet::new()) + } + fn join(&mut self, other: &Self) -> bool { + let before = self.0.len(); + self.0.extend(other.0.iter().copied()); + self.0.len() != before + } +} + +fn leq(a: &TokenSet, b: &TokenSet) -> bool { + a.0.is_subset(&b.0) +} + +// ---- a concrete graph + a gen/kill analysis over it ------------------------- + +struct Graph { + entry: usize, + succ: Vec>, +} + +impl ControlFlowGraph for Graph { + fn num_blocks(&self) -> usize { + self.succ.len() + } + fn entry(&self) -> usize { + self.entry + } + fn successors(&self, block: usize) -> &[usize] { + self.succ.get(block).map_or(&[], Vec::as_slice) + } +} + +/// Forward "reaching tokens": each block generates a token set; the transfer is +/// `out = in ∪ gen` (a monotone union). `in` at a block is the join of pred outs. +struct Reaching { + gen: Vec, + entry: TokenSet, +} + +impl Analysis for Reaching { + type Fact = TokenSet; + fn entry_fact(&self) -> TokenSet { + self.entry.clone() + } + fn transfer(&self, block: usize, in_fact: &TokenSet) -> TokenSet { + let mut out = in_fact.clone(); + if let Some(g) = self.gen.get(block) { + out.join(g); + } + out + } +} + +fn gens(per_block: &[&[u8]]) -> Vec { + per_block.iter().map(|b| TokenSet::of(b)).collect() +} + +// ---- CFG-shape tests -------------------------------------------------------- + +#[test] +fn straight_line() { + // 0 -> 1 -> 2, gens {0},{1},{2} + let g = Graph { + entry: 0, + succ: vec![vec![1], vec![2], vec![]], + }; + let a = Reaching { + gen: gens(&[&[0], &[1], &[2]]), + entry: TokenSet::bottom(), + }; + let sol = solve(&g, &a); + assert_eq!(sol.in_fact(0), Some(&TokenSet::of(&[]))); + assert_eq!(sol.in_fact(1), Some(&TokenSet::of(&[0]))); + assert_eq!(sol.in_fact(2), Some(&TokenSet::of(&[0, 1]))); +} + +#[test] +fn diamond_merges_both_arms() { + // 0 -> {1,2} -> 3. gens: 0:{0} 1:{1} 2:{2} 3:{3} + let g = Graph { + entry: 0, + succ: vec![vec![1, 2], vec![3], vec![3], vec![]], + }; + let a = Reaching { + gen: gens(&[&[0], &[1], &[2], &[3]]), + entry: TokenSet::bottom(), + }; + let sol = solve(&g, &a); + // block 3's in-fact is the union of both arms: {0,1} ∪ {0,2} = {0,1,2} + assert_eq!(sol.in_fact(3), Some(&TokenSet::of(&[0, 1, 2]))); +} + +#[test] +fn self_loop_reaches_fixpoint() { + // 0 -> 1, 1 -> 1 (self loop) and 1 -> 2 + let g = Graph { + entry: 0, + succ: vec![vec![1], vec![1, 2], vec![]], + }; + let a = Reaching { + gen: gens(&[&[0], &[1], &[2]]), + entry: TokenSet::bottom(), + }; + let sol = solve(&g, &a); + // 1 sees its own gen through the back-edge; in[1] = {0} ∪ {0,1} = {0,1} + assert_eq!(sol.in_fact(1), Some(&TokenSet::of(&[0, 1]))); + assert_eq!(sol.in_fact(2), Some(&TokenSet::of(&[0, 1]))); +} + +#[test] +fn nested_loop_reaches_fixpoint() { + // outer: 1..3, inner: 2 self-loops; 0->1->2->3->1 (outer back), 2->2 (inner) + let g = Graph { + entry: 0, + succ: vec![vec![1], vec![2], vec![2, 3], vec![1, 4], vec![]], + }; + let a = Reaching { + gen: gens(&[&[0], &[1], &[2], &[3], &[4]]), + entry: TokenSet::bottom(), + }; + let sol = solve(&g, &a); + // Everything before the exit accumulates; exit sees all of 0..3 plus its own. + assert_eq!(sol.in_fact(4), Some(&TokenSet::of(&[0, 1, 2, 3]))); + assert_eq!(sol.in_fact(2), Some(&TokenSet::of(&[0, 1, 2, 3]))); +} + +#[test] +fn unreachable_block_is_none_and_ignored() { + // 0 -> 1 ; block 2 is unreachable (nobody points at it) and gens a poison + // token that must NOT appear anywhere. + let g = Graph { + entry: 0, + succ: vec![vec![1], vec![], vec![1]], + }; + let a = Reaching { + gen: gens(&[&[0], &[1], &[99]]), + entry: TokenSet::bottom(), + }; + let sol = solve(&g, &a); + assert!(!sol.is_reachable(2)); + assert_eq!(sol.in_fact(2), None); + // block 1's in-fact must not include the unreachable block's gen (99), + // even though 2 -> 1 is an edge: an unreachable pred contributes nothing. + assert_eq!(sol.in_fact(1), Some(&TokenSet::of(&[0]))); +} + +#[test] +fn multiple_predecessors_join() { + // 0 -> {1,2,3} ; 1,2,3 -> 4. Four-way merge at 4. + let g = Graph { + entry: 0, + succ: vec![vec![1, 2, 3], vec![4], vec![4], vec![4], vec![]], + }; + let a = Reaching { + gen: gens(&[&[0], &[1], &[2], &[3], &[4]]), + entry: TokenSet::bottom(), + }; + let sol = solve(&g, &a); + assert_eq!(sol.in_fact(4), Some(&TokenSet::of(&[0, 1, 2, 3]))); +} + +#[test] +fn abnormal_early_exit() { + // 0 -> {1(early exit), 2}; 2 -> 3. Block 1 is a sink (return). Two exits. + let g = Graph { + entry: 0, + succ: vec![vec![1, 2], vec![], vec![3], vec![]], + }; + let a = Reaching { + gen: gens(&[&[0], &[1], &[2], &[3]]), + entry: TokenSet::bottom(), + }; + let sol = solve(&g, &a); + assert_eq!(sol.in_fact(1), Some(&TokenSet::of(&[0]))); // early-exit sink + assert_eq!(sol.in_fact(3), Some(&TokenSet::of(&[0, 2]))); +} + +// ---- worklist-order independence ------------------------------------------- + +#[test] +fn worklist_order_independence() { + // A CFG with a loop, a diamond and multiple predecessors — enough structure + // that a bad schedule would diverge if the result depended on order. + let g = Graph { + entry: 0, + succ: vec![ + vec![1], + vec![2, 3], + vec![4], + vec![4], + vec![1, 5], // back-edge 4->1 (loop) + exit + vec![], + ], + }; + let a = Reaching { + gen: gens(&[&[0], &[1], &[2], &[3], &[4], &[5]]), + entry: TokenSet::of(&[100]), + }; + + let schedules = [ + Schedule::Rpo, + Schedule::Postorder, + Schedule::Fifo, + Schedule::Lifo, + Schedule::BlockOrder, + ]; + let reference = solve_with(&g, &a, Schedule::Rpo); + for sched in schedules { + let sol = solve_with(&g, &a, sched); + for b in 0..g.num_blocks() { + assert_eq!( + sol.in_fact(b), + reference.in_fact(b), + "schedule {sched:?} diverged at block {b}" + ); + } + } +} + +// ---- the schedules are materially distinct orders --------------------------- + +/// A `Reaching` clone that records the block-visit sequence (interior mutability) +/// so a test can prove two schedules visit in genuinely different orders — not +/// merely that they agree on the result. +struct Recording<'a> { + gen: Vec, + entry: TokenSet, + order: &'a RefCell>, +} + +impl Analysis for Recording<'_> { + type Fact = TokenSet; + fn entry_fact(&self) -> TokenSet { + self.entry.clone() + } + fn transfer(&self, block: usize, in_fact: &TokenSet) -> TokenSet { + self.order.borrow_mut().push(block); + let mut out = in_fact.clone(); + if let Some(g) = self.gen.get(block) { + out.join(g); + } + out + } +} + +#[test] +fn schedules_are_materially_distinct_not_aliases() { + // A loop + diamond, so the visitation order genuinely diverges by schedule. + let g = Graph { + entry: 0, + succ: vec![ + vec![1], + vec![2, 3], + vec![4], + vec![4], + vec![1, 5], // 4 -> 1 back-edge (loop) + exit + vec![], + ], + }; + let gen = gens(&[&[0], &[1], &[2], &[3], &[4], &[5]]); + let entry = TokenSet::of(&[100]); + + let schedules = [ + Schedule::Rpo, + Schedule::Postorder, + Schedule::Fifo, + Schedule::Lifo, + Schedule::BlockOrder, + ]; + let mut sequences: Vec> = Vec::new(); + let mut solutions = Vec::new(); + for sched in schedules { + let order = RefCell::new(Vec::new()); + let rec = Recording { + gen: gen.clone(), + entry: entry.clone(), + order: &order, + }; + let sol = solve_with(&g, &rec, sched); + let facts: Vec> = (0..g.num_blocks()) + .map(|b| sol.in_fact(b).cloned()) + .collect(); + solutions.push(facts); + sequences.push(order.into_inner()); + } + + // Same fixpoint under every schedule. + let reference = solutions.first().expect("at least one schedule ran"); + for s in &solutions { + assert_eq!(s, reference, "schedules must agree on the solution"); + } + // But the visit ORDERS must be genuinely different — the old bug was that + // Fifo/Lifo/BlockOrder collapsed to the same order. Require at least three + // distinct sequences among the five. + let distinct: BTreeSet> = sequences.iter().cloned().collect(); + assert!( + distinct.len() >= 3, + "expected >= 3 distinct visitation orders, got {} (schedules are aliasing): {sequences:?}", + distinct.len() + ); + // Fifo pops the front (block 0 first); Lifo pops the back (highest-seeded + // block first) — so their first visited block differs. + assert_ne!( + sequences.get(2).and_then(|s| s.first()), + sequences.get(3).and_then(|s| s.first()), + "true FIFO and true LIFO must start at opposite ends" + ); +} + +// ---- structural permutations do not change the result ----------------------- + +#[test] +fn successor_order_permutation_is_invariant() { + // Same graph, but every block's successor list reversed. The join is + // commutative, so the converged solution must be identical. + let forward = Graph { + entry: 0, + succ: vec![vec![1, 2], vec![3], vec![3], vec![1, 4], vec![]], + }; + let reversed = Graph { + entry: 0, + succ: vec![vec![2, 1], vec![3], vec![3], vec![4, 1], vec![]], + }; + let a = Reaching { + gen: gens(&[&[0], &[1], &[2], &[3], &[4]]), + entry: TokenSet::bottom(), + }; + let sf = solve(&forward, &a); + let sr = solve(&reversed, &a); + for b in 0..forward.num_blocks() { + assert_eq!( + sf.in_fact(b), + sr.in_fact(b), + "successor-order permutation changed block {b}" + ); + } +} + +#[test] +fn block_id_permutation_is_invariant_when_mapped_back() { + // Relabel blocks by a permutation π, solve the relabelled graph, then map + // results back: in_fact(original b) must equal in_fact(π(b)) in the permuted + // solution. This also exercises a different seed/enqueue order. + let base = Graph { + entry: 0, + succ: vec![vec![1], vec![2, 3], vec![4], vec![4], vec![1, 5], vec![]], + }; + let gen0 = gens(&[&[0], &[1], &[2], &[3], &[4], &[5]]); + let a0 = Reaching { + gen: gen0.clone(), + entry: TokenSet::of(&[7]), + }; + let base_sol = solve(&base, &a0); + + // π: 0->5, 1->4, 2->3, 3->2, 4->1, 5->0 (reverse relabelling). + let n = base.num_blocks(); + let pi = |b: usize| n - 1 - b; + let mut succ = vec![Vec::new(); n]; + let mut gen = vec![TokenSet::bottom(); n]; + for b in 0..n { + let succ_b: Vec = base.successors(b).iter().map(|&s| pi(s)).collect(); + if let Some(slot) = succ.get_mut(pi(b)) { + *slot = succ_b; + } + if let (Some(dst), Some(src)) = (gen.get_mut(pi(b)), gen0.get(b)) { + *dst = src.clone(); + } + } + let permuted = Graph { entry: pi(0), succ }; + let a1 = Reaching { + gen, + entry: TokenSet::of(&[7]), + }; + let perm_sol = solve(&permuted, &a1); + + for b in 0..n { + assert_eq!( + base_sol.in_fact(b), + perm_sol.in_fact(pi(b)), + "block-ID permutation changed the mapped-back fact of block {b}" + ); + } +} + +// ---- lattice laws (exhaustive over a small state space) --------------------- + +fn all_subsets(universe: &[u8]) -> Vec { + let n = universe.len(); + let mut out = Vec::new(); + // 2^n subsets via bitmask (n small). + for mask in 0u32..(1u32 << n) { + let mut s = BTreeSet::new(); + for (i, &tok) in universe.iter().enumerate() { + if mask & (1u32 << i) != 0 { + s.insert(tok); + } + } + out.push(TokenSet(s)); + } + out +} + +#[test] +fn lattice_laws_hold_exhaustively() { + let universe = [1u8, 2, 3, 4]; // 16 subsets -> 16^3 triples, cheap + let elems = all_subsets(&universe); + + for x in &elems { + // idempotent: join(x, x) == x + let mut xx = x.clone(); + assert!(!xx.join(x), "join(x,x) must not report a change"); + assert_eq!(&xx, x, "join(x, x) == x"); + + // bottom identity + x <= join(x, y), and commutativity/associativity + assert!(leq(&TokenSet::bottom(), x), "bottom <= x"); + for y in &elems { + let mut xy = x.clone(); + xy.join(y); + let mut yx = y.clone(); + yx.join(x); + assert_eq!(xy, yx, "join commutative"); + assert!(leq(x, &xy), "x <= join(x, y)"); + assert!(leq(y, &xy), "y <= join(x, y)"); + + for z in &elems { + // associative: join(join(x,y),z) == join(x,join(y,z)) + let mut lhs = x.clone(); + lhs.join(y); + lhs.join(z); + let mut rhs_inner = y.clone(); + rhs_inner.join(z); + let mut rhs = x.clone(); + rhs.join(&rhs_inner); + assert_eq!(lhs, rhs, "join associative"); + } + } + } +} + +// ---- convergence guard ------------------------------------------------------ + +/// A deliberately BROKEN lattice whose `join` never stops growing (adds a fresh +/// token every time), to prove the convergence guard fires instead of hanging. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Diverging(u64); + +impl Lattice for Diverging { + fn bottom() -> Self { + Self(0) + } + fn join(&mut self, other: &Self) -> bool { + // Non-idempotent: always grows, so a looped block never stabilises. + self.0 = self.0.wrapping_add(other.0).wrapping_add(1); + true + } +} + +struct AlwaysGrow; +impl Analysis for AlwaysGrow { + type Fact = Diverging; + fn entry_fact(&self) -> Diverging { + Diverging(1) + } + fn transfer(&self, _block: usize, in_fact: &Diverging) -> Diverging { + Diverging(in_fact.0.wrapping_add(1)) + } +} + +#[test] +#[should_panic(expected = "did not converge")] +fn convergence_guard_fires_on_nonmonotone_join() { + // A loop between blocks 1 and 2 that does NOT pass through the entry (whose + // in-fact is pinned to the boundary), so the diverging join keeps growing on + // the back-edge forever: the guard must abort instead of hanging. + // 0 -> 1 -> 2 -> 1 (back-edge) + let g = Graph { + entry: 0, + succ: vec![vec![1], vec![2], vec![1]], + }; + let _ = solve(&g, &AlwaysGrow); +} diff --git a/rust/crates/own-cfg/src/buffers.rs b/rust/crates/own-cfg/src/buffers.rs index 2012e50f..ef642169 100644 --- a/rust/crates/own-cfg/src/buffers.rs +++ b/rust/crates/own-cfg/src/buffers.rs @@ -59,8 +59,9 @@ impl BufferMode { /// Modes whose backing storage may live on the stack (so the buffer must not /// escape the function). `scratch` is included — at runtime it *might* be the - /// stack arm. - const fn stack_backed(self) -> bool { + /// stack arm. Mirrors the Python `STACK_BACKED` membership. + #[must_use] + pub const fn stack_backed(self) -> bool { matches!(self, Self::Stack | Self::Scratch | Self::Inline) } } @@ -123,6 +124,16 @@ pub struct BufferInfo { pub line: u32, } +impl BufferInfo { + /// Whether this buffer's storage may live on the stack (the Python + /// `BufferInfo.stack_backed` property) — used by the ownership analysis to + /// reject an escaping stack-backed buffer (OWN015/OWN016). + #[must_use] + pub const fn stack_backed(&self) -> bool { + self.mode.stack_backed() + } +} + /// A named, reusable `policy { ... }` block of defaults (`buffers.Policy`). /// /// Settings preserve insertion order (irrelevant to resolution, which is diff --git a/rust/crates/own-cfg/src/lib.rs b/rust/crates/own-cfg/src/lib.rs index c3a59e41..e9a30321 100644 --- a/rust/crates/own-cfg/src/lib.rs +++ b/rust/crates/own-cfg/src/lib.rs @@ -27,11 +27,20 @@ pub mod json; use own_syntax::ast::Module; pub use buffers::{validate_policies, BufferInfo, BufferMode, Policies, Policy}; +// Re-export the instruction effect type so downstream analyses read it through +// the CFG seam (`own_cfg::Effect`) rather than depending on the parser crate +// directly — keeps `own-analysis` off a production `own-syntax` edge (P-022 DAG). pub use builder::{ build_cfg, collect_kinds, collect_policies, collect_resource_names, collect_signatures, }; pub use ir::{Block, BlockId, Cfg, Instr, Kind, Signature, SymId, Symbol}; pub use json::{canonical_json, module_cfg_value, python_dumps, CFG_JSON_VERSION}; +pub use own_syntax::ast::Effect; +// Re-export the AST module so downstream analyses that are genuinely AST-level +// (the lifetime/region analysis reads `lifetime` decls + `subscribe` statements, +// which the CFG does not model) consume it through the CFG seam — same pattern +// as `Effect`, keeping `own-analysis` off a direct `own-syntax` production edge. +pub use own_syntax::ast; /// A flow-insensitive resolver diagnostic — its code and 1-based source line. /// diff --git a/rust/crates/own-diagnostics/Cargo.toml b/rust/crates/own-diagnostics/Cargo.toml new file mode 100644 index 00000000..6bfc9e56 --- /dev/null +++ b/rust/crates/own-diagnostics/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "own-diagnostics" +description = "Diagnostic/Evidence verdict data types (an exact data-only port of ownlang/diagnostics.py); the presentation/SARIF projection is a later P-022 step" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +version = "0.1.0" + +# Per P-022 §crate-topology this crate is UPSTREAM of the solver: `own-analysis` +# *constructs* these types, so it depends on `own-diagnostics`, never the +# reverse. `own-diagnostics` itself may depend only on the span/location leaf +# (`own-ir`) — never on `own-syntax` (the parser) or `own-analysis` (the +# solver). It carries no workspace dependency today (the verdict `line` is a +# plain source line, matching the Python `Diagnostic`); `own-ir`'s `Span` enters +# only with the SARIF/evidence projection (step 5). The DAG edge test +# (`tests/dag.rs`) locks the allowed set. +[dependencies] +serde = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/rust/crates/own-diagnostics/src/diagnostic.rs b/rust/crates/own-diagnostics/src/diagnostic.rs new file mode 100644 index 00000000..f838d207 --- /dev/null +++ b/rust/crates/own-diagnostics/src/diagnostic.rs @@ -0,0 +1,439 @@ +//! The verdict data model — an exact, data-only port of the `ownlang` +//! `Severity` / `Evidence` / `Diagnostic` dataclasses. No rendering, no solver +//! state: these are the values `own-analysis` will construct and the oracle will +//! compare on `(path, line, code)`. + +use serde::{Deserialize, Serialize}; + +/// A diagnostic's severity. Serialises to the same `"error"`/`"warning"` +/// strings the Python `Severity` enum uses, so a future JSON/SARIF seam matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + /// A verdict that fails a build / red check. + #[default] + Error, + /// Advisory — reported, but does not by itself fail the check. + Warning, +} + +/// One secondary, structured location explaining a diagnostic. +/// +/// A single step in its reachability slice (where a resource was acquired, where +/// a borrow escapes, where a missing release should go). The primary +/// [`Diagnostic::line`] stays the anchor; evidence rides alongside it. +/// +/// A data-only mirror of the Python `Evidence` dataclass; the `note:` / SARIF +/// projection is a later step and is deliberately not implemented here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Evidence { + /// 1-based source line of this step. + pub line: u32, + /// Human label for the step ("acquired here", "escapes here", …). + pub label: String, + /// The file of this step; `None` means "same file as the diagnostic's anchor". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file: Option, + /// What this step is, for consumers that group/colour evidence: a plain + /// `"related"` by default, or a resource-protocol role + /// (`acquired`/`released`/`escaped`/`consumed`/`step`). + #[serde(default = "default_role")] + pub role: String, +} + +fn default_role() -> String { + "related".to_owned() +} + +impl Evidence { + /// A `"related"` step in the same file as its diagnostic's anchor. + #[must_use] + pub fn new(line: u32, label: impl Into) -> Self { + Self { + line, + label: label.into(), + file: None, + role: default_role(), + } + } + + /// Set an explicit protocol role (`acquired`/`released`/`escaped`/…). + #[must_use] + pub fn with_role(mut self, role: impl Into) -> Self { + self.role = role.into(); + self + } + + /// Set an explicit file (a cross-file evidence step). + #[must_use] + pub fn with_file(mut self, file: impl Into) -> Self { + self.file = Some(file.into()); + self + } +} + +/// A single ownership/lifetime/effect/DI verdict. A data-only mirror of the +/// Python `Diagnostic` dataclass, preserving its field order +/// (`code, message, line, severity, subject, resource_kind, evidence`). +/// +/// Construct with [`Diagnostic::new`]: a code absent from [`TITLES`] is a bug, +/// not a blank finding, so construction fails loudly — the same guard as the +/// Python `__post_init__`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diagnostic { + /// The diagnostic code (e.g. `"OWN001"`); must be present in [`TITLES`]. + pub code: String, + /// The human message. Not part of the `(path, line, code)` parity key at + /// this step (message-text parity is a later, fixture-backed contract). + pub message: String, + /// 1-based anchor line. + pub line: u32, + /// Severity; defaults to [`Severity::Error`]. + #[serde(default)] + pub severity: Severity, + /// A stable identity (`name#line`) of the subject, for report attribution. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + /// The resource's human "kind" (e.g. `"subscription token"`), when tagged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_kind: Option, + /// Ordered reachability slice; empty for a single-point finding. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub evidence: Vec, +} + +/// Returned by [`Diagnostic::new`] when handed a code with no [`TITLES`] entry — +/// the port of the Python "unknown diagnostic code" `ValueError`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnknownCode(pub String); + +impl std::fmt::Display for UnknownCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "unknown diagnostic code {:?} (not in own_diagnostics::TITLES); \ + a code and its title must be added together", + self.0 + ) + } +} + +impl std::error::Error for UnknownCode {} + +impl Diagnostic { + /// A single-point verdict at `line` with the default severity (`Error`) and + /// no subject/kind/evidence. Fails with [`UnknownCode`] if `code` is not a + /// known [`TITLES`] entry — mirroring the Python construction guard. + /// + /// # Errors + /// Returns [`UnknownCode`] when `code` has no [`TITLES`] entry. + pub fn new( + code: impl Into, + message: impl Into, + line: u32, + ) -> Result { + let code = code.into(); + if title(&code).is_none() { + return Err(UnknownCode(code)); + } + Ok(Self { + code, + message: message.into(), + line, + severity: Severity::Error, + subject: None, + resource_kind: None, + evidence: Vec::new(), + }) + } + + /// Override the severity (builder style). + #[must_use] + pub const fn with_severity(mut self, severity: Severity) -> Self { + self.severity = severity; + self + } + + /// Attach a subject identity (`name#line`). + #[must_use] + pub fn with_subject(mut self, subject: impl Into) -> Self { + self.subject = Some(subject.into()); + self + } + + /// Attach a resource kind (e.g. `"subscription token"`). + #[must_use] + pub fn with_resource_kind(mut self, kind: impl Into) -> Self { + self.resource_kind = Some(kind.into()); + self + } + + /// Attach an ordered evidence slice. + #[must_use] + pub fn with_evidence(mut self, evidence: Vec) -> Self { + self.evidence = evidence; + self + } + + /// The human title for this diagnostic's code (always `Some` for a value + /// built through [`Diagnostic::new`]). + #[must_use] + pub fn title(&self) -> Option<&'static str> { + title(&self.code) + } + + /// This diagnostic's `(line, code)` parity key — the per-file half of the + /// `(path, line, code)` oracle comparison surface. + #[must_use] + pub fn key(&self) -> DiagKey { + DiagKey { + line: self.line, + code: self.code.clone(), + } + } +} + +/// The per-diagnostic parity key: a `(line, code)` pair. +/// +/// The full oracle surface is `(path, line, code)` — the `path` is the input +/// identity, and a file's diagnostics are compared as the ordered `Vec` +/// in emission order, so list position pins the deterministic intra-location +/// (same line + code) order. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct DiagKey { + /// 1-based anchor line. + pub line: u32, + /// The diagnostic code. + pub code: String, +} + +impl DiagKey { + /// Construct a key directly (used by the fixture replay). + #[must_use] + pub fn new(line: u32, code: impl Into) -> Self { + Self { + line, + code: code.into(), + } + } +} + +/// The human title for a diagnostic `code`, or `None` if the code is unknown. +/// +/// A panic-free binary search over the sorted [`TITLES`] table (its ordering is +/// asserted by a unit test), so it never indexes out of bounds. +#[must_use] +pub fn title(code: &str) -> Option<&'static str> { + TITLES + .binary_search_by(|(c, _)| (*c).cmp(code)) + .ok() + .and_then(|i| TITLES.get(i)) + .map(|(_, t)| *t) +} + +/// Every diagnostic code and its human title — the complete verdict vocabulary. +/// +/// A verbatim port of `ownlang.diagnostics.TITLES`, kept **sorted by code** so +/// [`title`] can binary-search it; a unit test asserts the ordering and the +/// count so a hand edit that breaks either fails loudly. +pub static TITLES: &[(&str, &str)] = &[ + ( + "DI001", + "captive dependency: a shorter-lived service is captured by a longer-lived one", + ), + ( + "DI002", + "singleton captures a scoped service (captive dependency)", + ), + ( + "DI003", + "singleton captures a transient service (captive dependency)", + ), + ( + "DI004", + "scoped service resolved from the root provider (captured for the app lifetime)", + ), + ( + "DI005", + "disposable transient resolved from a long-lived scope (delayed disposal)", + ), + ( + "EFF001", + "reactive effect re-runs on an unstable dependency identity (render-time IO storm)", + ), + ( + "OBL001", + "obligation still open when a barrier fires (open on every path)", + ), + ( + "OBL002", + "obligation may still be open when a barrier fires (open on some path)", + ), + ( + "OBL003", + "obligation not closed before the method exits (on every path)", + ), + ( + "OBL004", + "obligation may not be closed before the method exits (on some path)", + ), + ( + "OBL005", + "protocol scope matched no reported method -- rule is dead (advisory)", + ), + ( + "OWN001", + "owned resource not released on all paths (possible leak)", + ), + ("OWN002", "use after release"), + ("OWN003", "double release"), + ("OWN004", "borrow escapes its scope"), + ("OWN005", "use after move"), + ("OWN006", "mutable borrow while a shared borrow is live"), + ("OWN007", "move while borrowed"), + ("OWN008", "release while borrowed"), + ( + "OWN009", + "use after possible release (released on some path)", + ), + ("OWN010", "use after possible move (moved on some path)"), + ( + "OWN011", + "mutable borrow while another mutable borrow is live", + ), + ("OWN012", "shared borrow while a mutable borrow is live"), + ("OWN013", "owner accessed while it is mutably borrowed"), + ( + "OWN014", + "value escapes to a longer-lived region (lifetime promotion)", + ), + ( + "OWN015", + "stack-backed buffer cannot escape the current function", + ), + ( + "OWN016", + "stack-backed buffer moved to a longer-lived owner", + ), + ( + "OWN017", + "movable buffer escape is not supported by code generation (PoC limitation)", + ), + ("OWN018", "buffer size must be an integer"), + ( + "OWN019", + "inline capacity too large for a stack-backed policy", + ), + ("OWN020", "unsupported construct (out of scope for the MVP)"), + ( + "OWN021", + "stack allocation requires a statically known bound", + ), + ( + "OWN023", + "scratch fallback forbidden but the size may exceed the inline limit", + ), + ("OWN024", "sensitive buffer is not cleared on release"), + ( + "OWN025", + "full-length view of a pooled buffer reaches past its logical length", + ), + ("OWN030", "undefined name"), + ("OWN031", "name already defined in this scope"), + ("OWN032", "owned resource copied without 'move'"), + ("OWN033", "function must return a value on all paths"), + ("OWN034", "operation requires an owned resource"), + ("OWN035", "return type mismatch"), + ("OWN036", "cyclic lifetime ordering"), + ( + "OWN040", + "call to an undeclared function (unknown calls are forbidden)", + ), + ("OWN041", "call argument mismatch"), + ( + "OWN050", + "declaring type unresolved -- leakage analysis skipped", + ), + ( + "OWN051", + "ownership transfer unverified -- local not checked past this call", + ), + ( + "OWN052", + "interprocedural summary inference failed -- method summaries skipped", + ), +]; + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn titles_are_sorted_and_unique() { + let codes: Vec<&str> = TITLES.iter().map(|(c, _)| *c).collect(); + let mut sorted = codes.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + codes, sorted, + "TITLES must be strictly sorted by code and unique (title() binary-searches it)" + ); + } + + #[test] + fn titles_count_matches_python_reference() { + // Locked to `len(ownlang.diagnostics.TITLES)` — a drift on either side is + // a real vocabulary change and must be made on both, together. + assert_eq!(TITLES.len(), 47); + } + + #[test] + fn title_lookup_is_panic_free_and_correct() { + assert_eq!( + title("OWN001"), + Some("owned resource not released on all paths (possible leak)") + ); + assert_eq!( + title("OWN020"), + Some("unsupported construct (out of scope for the MVP)") + ); + assert_eq!( + title("EFF001"), + Some( + "reactive effect re-runs on an unstable dependency identity (render-time IO storm)" + ) + ); + assert_eq!(title("NOPE999"), None); + assert_eq!(title(""), None); + } + + #[test] + fn new_rejects_unknown_code() { + let err = Diagnostic::new("OWN999", "nope", 1).unwrap_err(); + assert_eq!(err.0, "OWN999"); + assert!(err.to_string().contains("unknown diagnostic code")); + } + + #[test] + fn new_accepts_known_code_with_defaults() { + let d = Diagnostic::new("OWN001", "leak", 12).expect("OWN001 is known"); + assert_eq!(d.severity, Severity::Error); + assert_eq!(d.subject, None); + assert!(d.evidence.is_empty()); + assert_eq!(d.key(), DiagKey::new(12, "OWN001")); + assert!(d.title().is_some()); + } + + #[test] + fn severity_serialises_like_python() { + assert_eq!( + serde_json::to_string(&Severity::Error).unwrap(), + "\"error\"" + ); + assert_eq!( + serde_json::to_string(&Severity::Warning).unwrap(), + "\"warning\"" + ); + } +} diff --git a/rust/crates/own-diagnostics/src/lib.rs b/rust/crates/own-diagnostics/src/lib.rs new file mode 100644 index 00000000..43c1ed05 --- /dev/null +++ b/rust/crates/own-diagnostics/src/lib.rs @@ -0,0 +1,36 @@ +//! `own-diagnostics` — the Diagnostic / Evidence **verdict** data model +//! (P-022 step 4, issue #214), a data-only port of `ownlang/diagnostics.py`. +//! +//! This crate owns the verdict *types* the solver constructs. Per the P-022 +//! crate DAG it sits **upstream** of `own-analysis`: the solver depends on this +//! crate to build `Diagnostic`/`Evidence` values, so the arrow is +//! `own-analysis → own-diagnostics`, never the reverse. This crate depends only +//! on the span/location leaf (`own-ir`) — and today not even that, since a +//! verdict's `line` is a plain source line exactly as in the Python reference. +//! The presentation surface (human render, SARIF `relatedLocations`/`codeFlows`) +//! is a **later** step (5) and deliberately lives elsewhere: these are data, not +//! `Err`, and not rendering. +//! +//! What is faithful to the Python reference here: +//! +//! * `Severity` serialises to the same `"error"`/`"warning"` strings. +//! * `Evidence` carries `line`, `label`, an optional `file` (`None` ⇒ the +//! diagnostic's own file) and a `role` defaulting to `"related"`. +//! * `Diagnostic` carries `code`, `message`, `line`, `severity`, optional +//! `subject` and `resource_kind`, and an ordered `evidence` slice — the same +//! positional contract as the frozen Python dataclass. +//! * A `Diagnostic` **must** carry a code present in [`TITLES`]; constructing one +//! with an unknown code fails loudly ([`Diagnostic::new`] returns `Err`), +//! mirroring the Python `__post_init__` guard ("a code and its title must be +//! added together") — the one stringly-typed contract, checked at the seam. +//! +//! The parity **comparison surface** (issue #214) is `(path, line, code)` in +//! emission order; [`DiagKey`] is that per-diagnostic key. The frozen golden set +//! lives in `tests/fixtures/diag_parity.json` (regenerate: +//! `python tests/test_diag_fixtures.py --write`); `tests/fixture.rs` validates +//! the harness plumbing against it. The full replay (parse → lower → analyse → +//! compare) lands with `own-analysis` at the next checkpoint. + +mod diagnostic; + +pub use diagnostic::{title, DiagKey, Diagnostic, Evidence, Severity, UnknownCode, TITLES}; diff --git a/rust/crates/own-diagnostics/tests/dag.rs b/rust/crates/own-diagnostics/tests/dag.rs new file mode 100644 index 00000000..0d6f0dc3 --- /dev/null +++ b/rust/crates/own-diagnostics/tests/dag.rs @@ -0,0 +1,168 @@ +//! Architecture-fitness test (P-022 §"Architecture-fitness tooling"): the crate +//! graph IS the architecture, so lock the **allowed** workspace-internal edge +//! set with a `cargo metadata` parse. Cargo already makes a *cycle* a compile +//! error; this catches the subtler drift — a crate growing a dependency it is +//! architecturally forbidden from having (the #214 guardrail: "diagnostics never +//! depends on analysis; codegen untouched"). +//! +//! The load-bearing invariants (P-022 §fitness): +//! * `own-ir` is the leaf — it depends on no workspace crate. +//! * `own-diagnostics` (the verdict/contract layer) depends only on the +//! span/location leaf `own-ir`; **never** on `own-syntax` (the parser), +//! `own-cfg`, or `own-analysis` (the solver). The arrow is +//! `own-analysis → own-diagnostics`, not the reverse. +//! * `own-cfg` hangs off `own-syntax` (+ the leaf), nothing downstream. +//! +//! Encoded as `crate → allowed superset of workspace deps`; the test asserts the +//! *actual* edge set is a subset. Widening the map is a deliberate, reviewed act +//! — exactly the point (a future `own-codegen → own-analysis` edge, say, must be +//! added here on purpose, never slip in implicitly). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::Value; +use std::collections::{BTreeSet, HashMap}; +use std::process::Command; + +/// `crate → the workspace crates it is ALLOWED to depend on`. Any workspace +/// member not listed is required to have **no** workspace-internal dependency. +fn allowed_edges() -> HashMap<&'static str, BTreeSet<&'static str>> { + let mut m: HashMap<&'static str, BTreeSet<&'static str>> = HashMap::new(); + m.insert("own-ir", BTreeSet::new()); // leaf — depends on nothing + m.insert("own-syntax", std::iter::once("own-ir").collect()); + m.insert("own-cfg", ["own-ir", "own-syntax"].into_iter().collect()); + // The invariant #214 is about: only the span leaf, never the solver/parser. + m.insert("own-diagnostics", std::iter::once("own-ir").collect()); + // own-analysis CONSTRUCTS diagnostics and consumes the cfg lowering. It reads + // the effect type through `own_cfg::Effect`, NOT the parser — so there is no + // production own-syntax edge (own-syntax is a dev-only edge for its tests). + m.insert( + "own-analysis", + ["own-ir", "own-cfg", "own-diagnostics"] + .into_iter() + .collect(), + ); + m +} + +fn workspace_edges() -> HashMap> { + let manifest = concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"); + let out = Command::new(env!("CARGO")) + .args([ + "metadata", + "--no-deps", + "--format-version", + "1", + "--manifest-path", + manifest, + ]) + .output() + .expect("cargo metadata runs"); + assert!( + out.status.success(), + "cargo metadata failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let meta: Value = serde_json::from_slice(&out.stdout).expect("metadata JSON parses"); + let packages = meta + .get("packages") + .and_then(Value::as_array) + .expect("packages"); + + // With --no-deps, `packages` is exactly the workspace members. + let members: BTreeSet = packages + .iter() + .filter_map(|p| p.get("name").and_then(Value::as_str)) + .map(str::to_owned) + .collect(); + + let mut edges: HashMap> = HashMap::new(); + for pkg in packages { + let name = pkg + .get("name") + .and_then(Value::as_str) + .expect("pkg name") + .to_owned(); + let deps: BTreeSet = pkg + .get("dependencies") + .and_then(Value::as_array) + .expect("dependencies array") + .iter() + // Only NORMAL (production) deps are architecture edges: cargo tags a + // dev-dependency's `kind` as "dev" and a build-dep as "build"; a + // normal dep has `kind: null`. Test-only edges (a crate using another + // crate's parser in its *tests*) do not constrain the runtime DAG. + .filter(|d| d.get("kind").map_or(true, Value::is_null)) + .filter_map(|d| d.get("name").and_then(Value::as_str)) + .filter(|d| members.contains(*d)) // only workspace-internal edges are architecture + .map(str::to_owned) + .collect(); + edges.insert(name, deps); + } + edges +} + +#[test] +fn crate_graph_matches_the_allowed_dag() { + let allowed = allowed_edges(); + let actual = workspace_edges(); + + for (crate_name, deps) in &actual { + let permitted = allowed.get(crate_name.as_str()).unwrap_or_else(|| { + panic!( + "workspace member {crate_name:?} is not in the allowed-edge map — add it to \ + tests/dag.rs::allowed_edges with its intended (reviewed) dependency set" + ) + }); + for dep in deps { + assert!( + permitted.contains(dep.as_str()), + "FORBIDDEN EDGE: {crate_name} → {dep} is not in the allowed DAG. \ + If this edge is intended, widen allowed_edges() deliberately (and justify it)." + ); + } + } +} + +#[test] +fn own_diagnostics_never_depends_on_the_solver_or_parser() { + // The single most important #214 invariant, asserted directly so a regression + // names itself even if the map above is edited. + let actual = workspace_edges(); + let deps = actual + .get("own-diagnostics") + .expect("own-diagnostics is a member"); + for forbidden in ["own-analysis", "own-syntax", "own-cfg"] { + assert!( + !deps.contains(forbidden), + "own-diagnostics must not depend on {forbidden} \ + (verdict/contract layer stays independent of the solver and parser)" + ); + } +} + +#[test] +fn own_analysis_has_no_production_parser_edge() { + // The domain analyses read the effect type through `own_cfg::Effect`; a + // production dependency on the parser (`own-syntax`) must never return. + // (own-syntax stays a dev-dependency for the parity/metamorphic tests.) + let actual = workspace_edges(); + let deps = actual + .get("own-analysis") + .expect("own-analysis is a member"); + assert!( + !deps.contains("own-syntax"), + "own-analysis grew a PRODUCTION dependency on own-syntax; read the effect \ + type via own_cfg::Effect and keep own-syntax a dev-dependency" + ); +} + +#[test] +fn own_ir_is_a_leaf() { + let actual = workspace_edges(); + let deps = actual.get("own-ir").expect("own-ir is a member"); + assert!( + deps.is_empty(), + "own-ir is the leaf — it must depend on no workspace crate, got {deps:?}" + ); +} diff --git a/rust/crates/own-diagnostics/tests/fixture.rs b/rust/crates/own-diagnostics/tests/fixture.rs new file mode 100644 index 00000000..b2f31338 --- /dev/null +++ b/rust/crates/own-diagnostics/tests/fixture.rs @@ -0,0 +1,137 @@ +//! Comparison-harness plumbing against the frozen diagnostics-parity fixture +//! (`tests/fixtures/diag_parity.json`, authoritative via +//! `python tests/test_diag_fixtures.py --write`). +//! +//! This is the Rust side of the #214 oracle at **checkpoint 1**: the semantic +//! port (parse → lower → analyse → construct diagnostics) does not exist yet, so +//! this test does not *produce* diagnostics. What it locks now is the +//! comparison contract itself — the exact shape the replay will diff against: +//! +//! * the fixture parses into the `(name, source, diags)` case model; +//! * every `code` in every case is a **known** [`own_diagnostics::TITLES`] entry +//! (so the golden set can never freeze a titleless code — the same guard the +//! Python side enforces at construction); +//! * each case's diagnostics load into an ordered `Vec` preserving +//! emission order, which is the deterministic intra-location ordering the +//! oracle pins; +//! * the golden set round-trips through the parity types unchanged. +//! +//! When `own-analysis` lands, its `tests/parity.rs` replays each `source` +//! through the `check` surface and asserts `produced == case.expected` using +//! exactly this `expected` vector. The TODO below marks that seam. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use own_diagnostics::{title, DiagKey}; +use serde_json::Value; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/diag_parity.json" +); + +/// One frozen case: the input identity, its source, and the golden ordered +/// `(line, code)` verdict list the Rust `check` surface must reproduce. +struct Case { + name: String, + #[allow(dead_code)] // consumed by own-analysis's replay at the next checkpoint + source: String, + expected: Vec, +} + +fn load_cases() -> Vec { + let raw = std::fs::read_to_string(FIXTURE) + .expect("fixture missing — regenerate: python tests/test_diag_fixtures.py --write"); + let root: Value = serde_json::from_str(&raw).expect("diag_parity.json parses"); + let cases = root + .get("cases") + .and_then(Value::as_array) + .expect("'cases' array"); + cases + .iter() + .map(|case| { + let name = case + .get("name") + .and_then(Value::as_str) + .expect("case 'name'") + .to_owned(); + let source = case + .get("source") + .and_then(Value::as_str) + .expect("case 'source'") + .to_owned(); + let expected = case + .get("diags") + .and_then(Value::as_array) + .expect("case 'diags' array") + .iter() + .map(|pair| { + let pair = pair.as_array().expect("each diag is a [line, code] pair"); + let line = pair + .first() + .and_then(Value::as_u64) + .expect("diag line is a number"); + let code = pair + .get(1) + .and_then(Value::as_str) + .expect("diag code is a string"); + DiagKey::new(u32::try_from(line).expect("line fits u32"), code) + }) + .collect(); + Case { + name, + source, + expected, + } + }) + .collect() +} + +#[test] +fn fixture_loads_and_is_non_empty() { + let cases = load_cases(); + assert!(!cases.is_empty(), "diag parity corpus must not be empty"); + // The corpus + curated set is 60+ cases; guard against a truncated fixture. + assert!( + cases.len() >= 60, + "expected the full corpus (60+ cases), found {}", + cases.len() + ); +} + +#[test] +fn every_frozen_code_is_a_known_title() { + for case in load_cases() { + for key in &case.expected { + assert!( + title(&key.code).is_some(), + "case {:?} froze unknown code {:?} — the golden set must never \ + carry a titleless code (regenerate the fixture / add the title)", + case.name, + key.code + ); + } + } +} + +#[test] +fn diagnostics_load_in_emission_order() { + // Within a case the list order IS the contract (intra-location ordering). + // A round-trip through DiagKey must preserve it verbatim — no sorting, no + // dedup (the parity policy forbids sorting away meaningful duplicates). + for case in load_cases() { + let reloaded: Vec = case.expected.clone(); + assert_eq!( + reloaded, case.expected, + "DiagKey load must preserve emission order for case {:?}", + case.name + ); + } +} + +// TODO(#214, checkpoint 2 — own-analysis): replay each `case.source` through the +// ported `check` surface and assert the produced `Vec` equals +// `case.expected` exactly. That step needs the worklist solver + ownership / +// lifetime analyses, which land after this checkpoint's fixture + comparison +// design are reviewed. `Case.source` is carried now so the replay is a pure +// addition here, not a fixture reshape. diff --git a/tests/fixtures/di_eff_fact_parity.json b/tests/fixtures/di_eff_fact_parity.json new file mode 100644 index 00000000..dbd109df --- /dev/null +++ b/tests/fixtures/di_eff_fact_parity.json @@ -0,0 +1,803 @@ +{ + "comment": "GENERATED by tests/test_di_eff_fact_parity.py --write; do not edit. Python (ownlang.effects / ownlang.di) is authoritative; rust/crates/own-analysis/tests/fact_parity.rs replays the same facts and must reproduce the (file, line, code) verdicts exactly (#214).", + "effect_cases": [ + { + "name": "fresh_object_storm", + "effects": [ + { + "component": "A", + "deps": [ + "opts" + ], + "io": true, + "bindings": [ + { + "name": "opts", + "init": "object", + "refs": [], + "line": 1 + } + ], + "file": "A.tsx", + "line": 10 + } + ], + "expected": [ + [ + "A.tsx", + 10, + "EFF001" + ] + ] + }, + { + "name": "memoised_clean", + "effects": [ + { + "component": "A", + "deps": [ + "opts" + ], + "io": true, + "bindings": [ + { + "name": "opts", + "init": "memo", + "refs": [], + "line": 1 + } + ], + "file": "A.tsx", + "line": 10 + } + ], + "expected": [] + }, + { + "name": "no_io_clean", + "effects": [ + { + "component": "A", + "deps": [ + "opts" + ], + "io": false, + "bindings": [ + { + "name": "opts", + "init": "object", + "refs": [], + "line": 1 + } + ], + "file": "A.tsx", + "line": 10 + } + ], + "expected": [] + }, + { + "name": "derivation_chain", + "effects": [ + { + "component": "A", + "deps": [ + "c" + ], + "io": true, + "bindings": [ + { + "name": "a", + "init": "object", + "refs": [], + "line": 1 + }, + { + "name": "b", + "init": "ident", + "refs": [ + "a" + ], + "line": 1 + }, + { + "name": "c", + "init": "ident", + "refs": [ + "b" + ], + "line": 1 + } + ], + "file": "A.tsx", + "line": 12 + } + ], + "expected": [ + [ + "A.tsx", + 12, + "EFF001" + ] + ] + }, + { + "name": "opaque_call_unknown", + "effects": [ + { + "component": "A", + "deps": [ + "x" + ], + "io": true, + "bindings": [ + { + "name": "x", + "init": "call", + "refs": [], + "line": 1 + } + ], + "file": "A.tsx", + "line": 10 + } + ], + "expected": [] + }, + { + "name": "plain_identifier_stable", + "effects": [ + { + "component": "A", + "deps": [ + "props.id" + ], + "io": true, + "bindings": [], + "file": "A.tsx", + "line": 10 + } + ], + "expected": [] + }, + { + "name": "identity_cycle_safe", + "effects": [ + { + "component": "A", + "deps": [ + "a" + ], + "io": true, + "bindings": [ + { + "name": "a", + "init": "ident", + "refs": [ + "b" + ], + "line": 1 + }, + { + "name": "b", + "init": "ident", + "refs": [ + "a" + ], + "line": 1 + } + ], + "file": "A.tsx", + "line": 10 + } + ], + "expected": [] + }, + { + "name": "multi_file_equal_lines", + "effects": [ + { + "component": "Z", + "deps": [ + "o" + ], + "io": true, + "bindings": [ + { + "name": "o", + "init": "object", + "refs": [], + "line": 1 + } + ], + "file": "z.tsx", + "line": 7 + }, + { + "component": "B", + "deps": [ + "o" + ], + "io": true, + "bindings": [ + { + "name": "o", + "init": "array", + "refs": [], + "line": 1 + } + ], + "file": "b.tsx", + "line": 7 + } + ], + "expected": [ + [ + "b.tsx", + 7, + "EFF001" + ], + [ + "z.tsx", + 7, + "EFF001" + ] + ] + } + ], + "di_cases": [ + { + "name": "di001_direct", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [ + "Db" + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 6 + } + ], + "expected": [ + [ + "reg.cs", + 5, + "DI001" + ] + ] + }, + { + "name": "di001_transitive", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [ + "Mid" + ] + }, + { + "name": "Mid", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "deps": [ + "Db" + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 7 + } + ], + "expected": [ + [ + "reg.cs", + 5, + "DI001" + ] + ] + }, + { + "name": "di001_inner_singleton_not_double_reported", + "services": [ + { + "name": "A", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [ + "B" + ] + }, + { + "name": "B", + "lifetime": "singleton", + "file": "reg.cs", + "line": 6, + "deps": [ + "Db" + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 7 + } + ], + "expected": [ + [ + "reg.cs", + 6, + "DI001" + ] + ] + }, + { + "name": "di001_duplicate_scoped_reported_once", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [ + "M1", + "M2" + ] + }, + { + "name": "M1", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "deps": [ + "Db" + ] + }, + { + "name": "M2", + "lifetime": "transient", + "file": "reg.cs", + "line": 7, + "deps": [ + "Db" + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 8 + } + ], + "expected": [ + [ + "reg.cs", + 5, + "DI001" + ] + ] + }, + { + "name": "di_cycle_guard", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [ + "T" + ] + }, + { + "name": "T", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "deps": [ + "T", + "Db" + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 7 + } + ], + "expected": [ + [ + "reg.cs", + 5, + "DI001" + ] + ] + }, + { + "name": "di_unknown_lifetime_ignored", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [ + "Mystery", + "Db" + ] + }, + { + "name": "Mystery", + "lifetime": "prototype", + "file": "reg.cs", + "line": 6 + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 7 + } + ], + "expected": [ + [ + "reg.cs", + 5, + "DI001" + ] + ] + }, + { + "name": "di002_weak", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "weak_deps": [ + "Db" + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 6 + } + ], + "expected": [ + [ + "reg.cs", + 5, + "DI002" + ] + ] + }, + { + "name": "di003_transient_disposable", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "deps": [ + "Conn" + ] + }, + { + "name": "Conn", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "disposable": true + } + ], + "expected": [ + [ + "reg.cs", + 5, + "DI003" + ] + ] + }, + { + "name": "di004_direct_call_site_anchor", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "root_resolves": [ + "Conn" + ], + "root_resolve_sites": [ + [ + "Conn", + "call.cs", + 42 + ] + ] + }, + { + "name": "Conn", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "disposable": true + } + ], + "expected": [ + [ + "call.cs", + 42, + "DI004" + ] + ] + }, + { + "name": "di004_transitive_disposable_entry_call_site", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "root_resolves": [ + "Mid" + ], + "root_resolve_sites": [ + [ + "Mid", + "call.cs", + 42 + ] + ] + }, + { + "name": "Mid", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "deps": [ + "Conn" + ] + }, + { + "name": "Conn", + "lifetime": "transient", + "file": "reg.cs", + "line": 7, + "disposable": true + } + ], + "expected": [ + [ + "call.cs", + 42, + "DI004" + ] + ] + }, + { + "name": "di005_direct_cache_site_anchor", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "scope_cached": [ + "Db" + ], + "scope_cache_sites": [ + [ + "Db", + "store.cs", + 50 + ] + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 6 + } + ], + "expected": [ + [ + "store.cs", + 50, + "DI005" + ] + ] + }, + { + "name": "di005_transitive_scoped_entry_cache_site", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "scope_cached": [ + "Mid" + ], + "scope_cache_sites": [ + [ + "Mid", + "store.cs", + 50 + ] + ] + }, + { + "name": "Mid", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "deps": [ + "Db" + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 7 + } + ], + "expected": [ + [ + "store.cs", + 50, + "DI005" + ] + ] + }, + { + "name": "di_multi_file_ordering", + "services": [ + { + "name": "A2", + "lifetime": "singleton", + "file": "z.cs", + "line": 3, + "deps": [ + "Db2" + ] + }, + { + "name": "Db2", + "lifetime": "scoped", + "file": "z.cs", + "line": 4 + }, + { + "name": "A1", + "lifetime": "singleton", + "file": "a.cs", + "line": 9, + "deps": [ + "Db1" + ] + }, + { + "name": "Db1", + "lifetime": "scoped", + "file": "a.cs", + "line": 10 + } + ], + "expected": [ + [ + "a.cs", + 9, + "DI001" + ], + [ + "z.cs", + 3, + "DI001" + ] + ] + }, + { + "name": "di004_duplicate_entry_sites_last_wins", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "root_resolves": [ + "Conn" + ], + "root_resolve_sites": [ + [ + "Conn", + "first.cs", + 10 + ], + [ + "Conn", + "second.cs", + 20 + ] + ] + }, + { + "name": "Conn", + "lifetime": "transient", + "file": "reg.cs", + "line": 6, + "disposable": true + } + ], + "expected": [ + [ + "second.cs", + 20, + "DI004" + ] + ] + }, + { + "name": "di005_duplicate_entry_sites_last_wins", + "services": [ + { + "name": "App", + "lifetime": "singleton", + "file": "reg.cs", + "line": 5, + "scope_cached": [ + "Db" + ], + "scope_cache_sites": [ + [ + "Db", + "first.cs", + 30 + ], + [ + "Db", + "second.cs", + 40 + ] + ] + }, + { + "name": "Db", + "lifetime": "scoped", + "file": "reg.cs", + "line": 6 + } + ], + "expected": [ + [ + "second.cs", + 40, + "DI005" + ] + ] + } + ] +} diff --git a/tests/fixtures/diag_diff_gen.json b/tests/fixtures/diag_diff_gen.json new file mode 100644 index 00000000..e6ec8e5d --- /dev/null +++ b/tests/fixtures/diag_diff_gen.json @@ -0,0 +1,2575 @@ +{ + "comment": "GENERATED by tests/test_diff_gen_fixtures.py --write; do not edit. Deterministic seeded ownership mini-programs; Python (ownlang) is authoritative, rust/crates/own-analysis/tests/diff_gen.rs replays each and prints seed+source on divergence (issue #214).", + "cases": [ + { + "seed": 0, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n while (n) {\n }\n let v1 = acquire R(1);\n let v2 = move v1;\n return;\n}\n", + "diags": [ + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 1, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = move v1;\n while (n) {\n Use(v2);\n use v2;\n }\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ], + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 2, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n Take(v1);\n let v4 = move v3;\n release v1;\n use v4;\n if (n) {\n if (n) {\n Use(v3);\n }\n use v3;\n Take(v3);\n }\n if (n) {\n release v2;\n release v2;\n use v2;\n }\n return;\n}\n", + "diags": [ + [ + 11, + "OWN002" + ], + [ + 15, + "OWN005" + ], + [ + 17, + "OWN005" + ], + [ + 18, + "OWN005" + ], + [ + 22, + "OWN003" + ], + [ + 23, + "OWN002" + ], + [ + 25, + "OWN001" + ], + [ + 25, + "OWN001" + ] + ] + }, + { + "seed": 3, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 4, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n if (n) {\n release v1;\n use v1;\n }\n if (n) {\n use v1;\n if (n) {\n release v1;\n }\n }\n return;\n}\n", + "diags": [ + [ + 9, + "OWN002" + ], + [ + 12, + "OWN009" + ], + [ + 14, + "OWN003" + ], + [ + 17, + "OWN001" + ] + ] + }, + { + "seed": 5, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n while (n) {\n release v1;\n }\n }\n Use(v1);\n if (n) {\n use v1;\n use v1;\n Use(v1);\n }\n if (n) {\n use v1;\n }\n return;\n}\n", + "diags": [ + [ + 9, + "OWN003" + ], + [ + 12, + "OWN009" + ], + [ + 14, + "OWN009" + ], + [ + 15, + "OWN009" + ], + [ + 16, + "OWN009" + ], + [ + 19, + "OWN009" + ], + [ + 21, + "OWN001" + ] + ] + }, + { + "seed": 6, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n use v1;\n release v1;\n }\n Use(v1);\n let v2 = move v1;\n release v2;\n let v3 = acquire R(1);\n release v3;\n let v4 = move v3;\n let v5 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 8, + "OWN009" + ], + [ + 9, + "OWN003" + ], + [ + 11, + "OWN009" + ], + [ + 12, + "OWN009" + ], + [ + 16, + "OWN002" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ] + ] + }, + { + "seed": 7, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n }\n let v1 = acquire R(1);\n release v1;\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 8, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN001" + ], + [ + 10, + "OWN001" + ] + ] + }, + { + "seed": 9, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Use(v1);\n return;\n}\n", + "diags": [ + [ + 8, + "OWN001" + ] + ] + }, + { + "seed": 10, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n if (n) {\n if (n) {\n release v1;\n }\n Use(v1);\n }\n let v2 = acquire R(1);\n while (n) {\n use v1;\n while (n) {\n use v2;\n release v2;\n }\n }\n if (n) {\n while (n) {\n Take(v1);\n }\n }\n Take(v1);\n release v2;\n return;\n}\n", + "diags": [ + [ + 11, + "OWN009" + ], + [ + 15, + "OWN009" + ], + [ + 17, + "OWN009" + ], + [ + 18, + "OWN003" + ], + [ + 23, + "OWN009" + ], + [ + 26, + "OWN009" + ], + [ + 27, + "OWN003" + ] + ] + }, + { + "seed": 11, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n }\n let v1 = acquire R(1);\n while (n) {\n release v1;\n if (n) {\n use v1;\n Use(v1);\n }\n }\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 12, + "OWN003" + ], + [ + 14, + "OWN002" + ], + [ + 15, + "OWN002" + ], + [ + 20, + "OWN001" + ], + [ + 20, + "OWN001" + ], + [ + 20, + "OWN001" + ] + ] + }, + { + "seed": 12, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n while (n) {\n while (n) {\n }\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 13, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n return;\n}\n", + "diags": [] + }, + { + "seed": 14, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n return;\n}\n", + "diags": [] + }, + { + "seed": 15, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n use v1;\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN001" + ], + [ + 10, + "OWN001" + ], + [ + 10, + "OWN001" + ] + ] + }, + { + "seed": 16, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n while (n) {\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 17, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n release v2;\n if (n) {\n if (n) {\n }\n while (n) {\n release v1;\n release v1;\n }\n }\n return;\n}\n", + "diags": [ + [ + 15, + "OWN003" + ], + [ + 16, + "OWN003" + ], + [ + 19, + "OWN001" + ] + ] + }, + { + "seed": 18, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n release v1;\n Use(v1);\n }\n while (n) {\n Use(v1);\n }\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 8, + "OWN003" + ], + [ + 9, + "OWN002" + ], + [ + 12, + "OWN009" + ], + [ + 15, + "OWN001" + ], + [ + 15, + "OWN001" + ] + ] + }, + { + "seed": 19, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n release v2;\n Use(v2);\n let v3 = move v1;\n let v4 = move v2;\n Use(v3);\n Use(v3);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN002" + ], + [ + 11, + "OWN002" + ], + [ + 14, + "OWN001" + ], + [ + 14, + "OWN001" + ] + ] + }, + { + "seed": 20, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Use(v1);\n if (n) {\n use v1;\n release v1;\n }\n while (n) {\n Use(v1);\n release v1;\n while (n) {\n Take(v1);\n }\n }\n if (n) {\n release v1;\n }\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 13, + "OWN009" + ], + [ + 14, + "OWN003" + ], + [ + 16, + "OWN002" + ], + [ + 20, + "OWN003" + ], + [ + 23, + "OWN001" + ], + [ + 23, + "OWN001" + ] + ] + }, + { + "seed": 21, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 11, + "OWN001" + ] + ] + }, + { + "seed": 22, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n while (n) {\n }\n }\n Use(v1);\n return;\n}\n", + "diags": [ + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 23, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = move v2;\n if (n) {\n release v3;\n release v2;\n }\n while (n) {\n release v3;\n }\n while (n) {\n use v1;\n }\n while (n) {\n use v3;\n while (n) {\n Use(v2);\n release v1;\n }\n }\n Use(v3);\n return;\n}\n", + "diags": [ + [ + 11, + "OWN005" + ], + [ + 14, + "OWN003" + ], + [ + 20, + "OWN009" + ], + [ + 22, + "OWN005" + ], + [ + 23, + "OWN003" + ], + [ + 26, + "OWN009" + ], + [ + 27, + "OWN001" + ], + [ + 27, + "OWN001" + ] + ] + }, + { + "seed": 24, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n use v1;\n let v2 = acquire R(1);\n while (n) {\n while (n) {\n use v1;\n release v2;\n }\n while (n) {\n }\n }\n while (n) {\n while (n) {\n release v2;\n }\n }\n return;\n}\n", + "diags": [ + [ + 14, + "OWN003" + ], + [ + 21, + "OWN003" + ], + [ + 24, + "OWN001" + ], + [ + 24, + "OWN001" + ] + ] + }, + { + "seed": 25, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n release v1;\n while (n) {\n }\n let v2 = acquire R(1);\n let v3 = move v2;\n let v4 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ], + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 26, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n }\n if (n) {\n while (n) {\n }\n }\n let v1 = acquire R(1);\n release v1;\n use v1;\n return;\n}\n", + "diags": [ + [ + 16, + "OWN002" + ] + ] + }, + { + "seed": 27, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = move v2;\n let v4 = move v2;\n if (n) {\n release v2;\n }\n while (n) {\n while (n) {\n use v4;\n }\n }\n if (n) {\n use v4;\n use v4;\n }\n return;\n}\n", + "diags": [ + [ + 11, + "OWN005" + ], + [ + 13, + "OWN005" + ], + [ + 24, + "OWN001" + ], + [ + 24, + "OWN001" + ], + [ + 24, + "OWN001" + ] + ] + }, + { + "seed": 28, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n return;\n}\n", + "diags": [] + }, + { + "seed": 29, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Use(v1);\n Take(v1);\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n let v4 = acquire R(1);\n while (n) {\n release v3;\n use v4;\n }\n return;\n}\n", + "diags": [ + [ + 13, + "OWN003" + ], + [ + 16, + "OWN001" + ], + [ + 16, + "OWN001" + ], + [ + 16, + "OWN001" + ] + ] + }, + { + "seed": 30, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n release v1;\n let v2 = acquire R(1);\n if (n) {\n while (n) {\n use v2;\n }\n use v1;\n }\n let v3 = acquire R(1);\n let v4 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 13, + "OWN002" + ], + [ + 17, + "OWN001" + ], + [ + 17, + "OWN001" + ], + [ + 17, + "OWN001" + ] + ] + }, + { + "seed": 31, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN001" + ] + ] + }, + { + "seed": 32, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n return;\n}\n", + "diags": [] + }, + { + "seed": 33, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n if (n) {\n }\n }\n while (n) {\n while (n) {\n }\n }\n while (n) {\n while (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 34, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n release v1;\n if (n) {\n use v1;\n }\n Use(v1);\n let v2 = acquire R(1);\n let v3 = move v2;\n return;\n}\n", + "diags": [ + [ + 9, + "OWN002" + ], + [ + 11, + "OWN002" + ], + [ + 14, + "OWN001" + ] + ] + }, + { + "seed": 35, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n if (n) {\n }\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 36, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = move v1;\n let v4 = acquire R(1);\n use v2;\n return;\n}\n", + "diags": [ + [ + 11, + "OWN001" + ], + [ + 11, + "OWN001" + ], + [ + 11, + "OWN001" + ] + ] + }, + { + "seed": 37, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n Use(v2);\n while (n) {\n Take(v2);\n release v1;\n }\n let v3 = acquire R(1);\n if (n) {\n if (n) {\n }\n }\n if (n) {\n if (n) {\n }\n if (n) {\n }\n if (n) {\n }\n }\n if (n) {\n use v1;\n }\n return;\n}\n", + "diags": [ + [ + 10, + "OWN009" + ], + [ + 11, + "OWN003" + ], + [ + 27, + "OWN009" + ], + [ + 29, + "OWN001" + ], + [ + 29, + "OWN001" + ], + [ + 29, + "OWN001" + ] + ] + }, + { + "seed": 38, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n }\n while (n) {\n if (n) {\n }\n }\n while (n) {\n if (n) {\n }\n }\n while (n) {\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 39, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n if (n) {\n if (n) {\n }\n while (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 40, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n release v1;\n release v1;\n Take(v1);\n let v2 = acquire R(1);\n use v1;\n return;\n}\n", + "diags": [ + [ + 8, + "OWN003" + ], + [ + 9, + "OWN002" + ], + [ + 11, + "OWN002" + ], + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 41, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n while (n) {\n }\n }\n let v1 = acquire R(1);\n while (n) {\n release v1;\n }\n return;\n}\n", + "diags": [ + [ + 14, + "OWN003" + ], + [ + 16, + "OWN001" + ] + ] + }, + { + "seed": 42, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = move v1;\n release v1;\n let v4 = acquire R(1);\n let v5 = acquire R(1);\n if (n) {\n use v1;\n }\n release v2;\n return;\n}\n", + "diags": [ + [ + 9, + "OWN005" + ], + [ + 13, + "OWN002" + ], + [ + 16, + "OWN001" + ], + [ + 16, + "OWN001" + ], + [ + 16, + "OWN001" + ] + ] + }, + { + "seed": 43, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n while (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 44, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n use v1;\n release v1;\n let v2 = acquire R(1);\n release v1;\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN003" + ], + [ + 12, + "OWN001" + ], + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 45, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN001" + ], + [ + 10, + "OWN001" + ] + ] + }, + { + "seed": 46, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 11, + "OWN001" + ] + ] + }, + { + "seed": 47, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n if (n) {\n Take(v1);\n if (n) {\n Take(v1);\n }\n use v1;\n }\n let v2 = acquire R(1);\n let v3 = move v2;\n let v4 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN002" + ], + [ + 12, + "OWN002" + ], + [ + 17, + "OWN001" + ], + [ + 17, + "OWN001" + ], + [ + 17, + "OWN001" + ] + ] + }, + { + "seed": 48, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n while (n) {\n release v1;\n if (n) {\n release v1;\n release v1;\n }\n use v1;\n }\n return;\n}\n", + "diags": [ + [ + 10, + "OWN003" + ], + [ + 12, + "OWN003" + ], + [ + 13, + "OWN003" + ], + [ + 15, + "OWN002" + ], + [ + 17, + "OWN001" + ] + ] + }, + { + "seed": 49, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN001" + ] + ] + }, + { + "seed": 50, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n }\n let v1 = acquire R(1);\n use v1;\n return;\n}\n", + "diags": [ + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 51, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n return;\n}\n", + "diags": [] + }, + { + "seed": 52, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n Use(v1);\n release v1;\n }\n use v1;\n while (n) {\n use v1;\n if (n) {\n Use(v1);\n use v1;\n }\n release v1;\n }\n release v1;\n return;\n}\n", + "diags": [ + [ + 8, + "OWN009" + ], + [ + 9, + "OWN003" + ], + [ + 11, + "OWN009" + ], + [ + 13, + "OWN009" + ], + [ + 15, + "OWN009" + ], + [ + 16, + "OWN009" + ], + [ + 18, + "OWN003" + ], + [ + 20, + "OWN003" + ] + ] + }, + { + "seed": 53, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n while (n) {\n }\n while (n) {\n }\n }\n let v1 = acquire R(1);\n use v1;\n use v1;\n let v2 = acquire R(1);\n use v1;\n return;\n}\n", + "diags": [ + [ + 17, + "OWN001" + ], + [ + 17, + "OWN001" + ] + ] + }, + { + "seed": 54, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n if (n) {\n }\n if (n) {\n while (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 55, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n return;\n}\n", + "diags": [] + }, + { + "seed": 56, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n if (n) {\n Use(v1);\n }\n if (n) {\n }\n }\n let v2 = move v1;\n let v3 = acquire R(1);\n release v1;\n use v2;\n let v4 = acquire R(1);\n use v4;\n release v2;\n return;\n}\n", + "diags": [ + [ + 16, + "OWN005" + ], + [ + 21, + "OWN001" + ], + [ + 21, + "OWN001" + ] + ] + }, + { + "seed": 57, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n release v1;\n return;\n}\n", + "diags": [] + }, + { + "seed": 58, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n release v1;\n if (n) {\n }\n while (n) {\n Use(v1);\n use v1;\n release v1;\n }\n return;\n}\n", + "diags": [ + [ + 11, + "OWN002" + ], + [ + 12, + "OWN002" + ], + [ + 13, + "OWN003" + ] + ] + }, + { + "seed": 59, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n release v1;\n }\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 8, + "OWN003" + ], + [ + 12, + "OWN001" + ], + [ + 12, + "OWN001" + ], + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 60, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 61, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 7, + "OWN001" + ] + ] + }, + { + "seed": 62, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n release v1;\n let v2 = move v1;\n use v1;\n use v1;\n let v3 = move v1;\n return;\n}\n", + "diags": [ + [ + 8, + "OWN002" + ], + [ + 9, + "OWN005" + ], + [ + 10, + "OWN005" + ], + [ + 11, + "OWN005" + ], + [ + 12, + "OWN001" + ], + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 63, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n let v1 = acquire R(1);\n if (n) {\n use v1;\n while (n) {\n }\n use v1;\n }\n let v2 = acquire R(1);\n release v1;\n Take(v1);\n return;\n}\n", + "diags": [ + [ + 17, + "OWN002" + ], + [ + 18, + "OWN001" + ] + ] + }, + { + "seed": 64, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n if (n) {\n while (n) {\n release v1;\n }\n while (n) {\n release v1;\n use v1;\n Use(v1);\n }\n use v1;\n }\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n use v1;\n let v4 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN003" + ], + [ + 12, + "OWN003" + ], + [ + 13, + "OWN002" + ], + [ + 14, + "OWN002" + ], + [ + 16, + "OWN009" + ], + [ + 20, + "OWN009" + ], + [ + 22, + "OWN001" + ], + [ + 22, + "OWN001" + ], + [ + 22, + "OWN001" + ], + [ + 22, + "OWN001" + ] + ] + }, + { + "seed": 65, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n while (n) {\n }\n }\n while (n) {\n while (n) {\n }\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 17, + "OWN001" + ] + ] + }, + { + "seed": 66, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 67, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n if (n) {\n Take(v1);\n Use(v1);\n }\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN002" + ], + [ + 12, + "OWN001" + ], + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 68, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n while (n) {\n }\n }\n while (n) {\n while (n) {\n }\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n while (n) {\n }\n let v3 = acquire R(1);\n let v4 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 20, + "OWN001" + ], + [ + 20, + "OWN001" + ], + [ + 20, + "OWN001" + ], + [ + 20, + "OWN001" + ] + ] + }, + { + "seed": 69, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n use v1;\n Take(v2);\n while (n) {\n if (n) {\n }\n if (n) {\n Take(v1);\n Take(v1);\n }\n }\n use v1;\n use v2;\n release v1;\n return;\n}\n", + "diags": [ + [ + 14, + "OWN009" + ], + [ + 15, + "OWN002" + ], + [ + 18, + "OWN009" + ], + [ + 19, + "OWN002" + ], + [ + 20, + "OWN009" + ] + ] + }, + { + "seed": 70, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN001" + ] + ] + }, + { + "seed": 71, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = move v1;\n release v2;\n let v3 = acquire R(1);\n release v3;\n return;\n}\n", + "diags": [] + }, + { + "seed": 72, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n return;\n}\n", + "diags": [] + }, + { + "seed": 73, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n release v1;\n if (n) {\n Use(v1);\n }\n }\n while (n) {\n use v1;\n }\n if (n) {\n Take(v1);\n }\n while (n) {\n Use(v1);\n if (n) {\n release v1;\n Take(v1);\n release v1;\n }\n }\n return;\n}\n", + "diags": [ + [ + 8, + "OWN003" + ], + [ + 10, + "OWN002" + ], + [ + 14, + "OWN009" + ], + [ + 17, + "OWN009" + ], + [ + 20, + "OWN009" + ], + [ + 22, + "OWN003" + ], + [ + 23, + "OWN002" + ], + [ + 24, + "OWN002" + ], + [ + 27, + "OWN001" + ] + ] + }, + { + "seed": 74, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Take(v1);\n use v1;\n Take(v1);\n while (n) {\n Take(v1);\n }\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n use v1;\n while (n) {\n if (n) {\n release v2;\n }\n }\n return;\n}\n", + "diags": [ + [ + 8, + "OWN002" + ], + [ + 9, + "OWN002" + ], + [ + 11, + "OWN002" + ], + [ + 15, + "OWN002" + ], + [ + 18, + "OWN003" + ], + [ + 21, + "OWN001" + ], + [ + 21, + "OWN001" + ] + ] + }, + { + "seed": 75, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n }\n let v1 = acquire R(1);\n Take(v1);\n let v2 = move v1;\n Take(v2);\n return;\n}\n", + "diags": [ + [ + 12, + "OWN002" + ] + ] + }, + { + "seed": 76, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n let v1 = acquire R(1);\n if (n) {\n }\n let v2 = move v1;\n while (n) {\n use v2;\n }\n return;\n}\n", + "diags": [ + [ + 15, + "OWN001" + ] + ] + }, + { + "seed": 77, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = move v1;\n release v1;\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 8, + "OWN005" + ], + [ + 10, + "OWN001" + ], + [ + 10, + "OWN001" + ] + ] + }, + { + "seed": 78, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = move v1;\n if (n) {\n while (n) {\n }\n }\n if (n) {\n release v2;\n Use(v1);\n while (n) {\n }\n }\n if (n) {\n while (n) {\n release v1;\n }\n }\n Use(v1);\n let v3 = acquire R(1);\n release v1;\n return;\n}\n", + "diags": [ + [ + 14, + "OWN005" + ], + [ + 20, + "OWN003" + ], + [ + 23, + "OWN005" + ], + [ + 25, + "OWN003" + ], + [ + 26, + "OWN001" + ], + [ + 26, + "OWN001" + ] + ] + }, + { + "seed": 79, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n }\n while (n) {\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 80, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n if (n) {\n }\n if (n) {\n }\n }\n let v1 = acquire R(1);\n if (n) {\n release v1;\n }\n use v1;\n use v1;\n return;\n}\n", + "diags": [ + [ + 18, + "OWN009" + ], + [ + 19, + "OWN009" + ], + [ + 20, + "OWN001" + ] + ] + }, + { + "seed": 81, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n }\n while (n) {\n if (n) {\n }\n while (n) {\n }\n }\n let v1 = acquire R(1);\n release v1;\n use v1;\n return;\n}\n", + "diags": [ + [ + 18, + "OWN002" + ] + ] + }, + { + "seed": 82, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN001" + ] + ] + }, + { + "seed": 83, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n if (n) {\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 84, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n if (n) {\n release v1;\n Use(v1);\n }\n }\n let v2 = move v1;\n if (n) {\n if (n) {\n release v2;\n }\n use v1;\n }\n use v1;\n while (n) {\n while (n) {\n use v1;\n }\n release v1;\n Take(v1);\n }\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN003" + ], + [ + 10, + "OWN002" + ], + [ + 13, + "OWN009" + ], + [ + 18, + "OWN005" + ], + [ + 20, + "OWN005" + ], + [ + 23, + "OWN005" + ], + [ + 25, + "OWN005" + ], + [ + 26, + "OWN002" + ], + [ + 29, + "OWN001" + ], + [ + 29, + "OWN001" + ] + ] + }, + { + "seed": 85, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Use(v1);\n while (n) {\n if (n) {\n }\n Take(v1);\n }\n if (n) {\n use v1;\n }\n return;\n}\n", + "diags": [ + [ + 11, + "OWN009" + ], + [ + 14, + "OWN009" + ], + [ + 16, + "OWN001" + ] + ] + }, + { + "seed": 86, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Use(v1);\n let v2 = move v1;\n let v3 = acquire R(1);\n let v4 = acquire R(1);\n if (n) {\n use v2;\n release v3;\n }\n let v5 = acquire R(1);\n use v4;\n let v6 = move v1;\n return;\n}\n", + "diags": [ + [ + 17, + "OWN005" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ] + ] + }, + { + "seed": 87, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = move v1;\n Take(v1);\n return;\n}\n", + "diags": [ + [ + 8, + "OWN005" + ], + [ + 9, + "OWN001" + ] + ] + }, + { + "seed": 88, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n Use(v2);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN001" + ], + [ + 9, + "OWN001" + ] + ] + }, + { + "seed": 89, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n return;\n}\n", + "diags": [] + }, + { + "seed": 90, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n if (n) {\n }\n Take(v1);\n Take(v1);\n }\n let v2 = acquire R(1);\n use v1;\n return;\n}\n", + "diags": [ + [ + 10, + "OWN009" + ], + [ + 11, + "OWN002" + ], + [ + 14, + "OWN009" + ], + [ + 15, + "OWN001" + ], + [ + 15, + "OWN001" + ] + ] + }, + { + "seed": 91, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 92, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n use v1;\n }\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n let v4 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ], + [ + 13, + "OWN001" + ], + [ + 13, + "OWN001" + ], + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 93, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n while (n) {\n }\n }\n if (n) {\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n Use(v2);\n let v3 = acquire R(1);\n release v1;\n while (n) {\n if (n) {\n }\n }\n return;\n}\n", + "diags": [ + [ + 21, + "OWN001" + ], + [ + 21, + "OWN001" + ] + ] + }, + { + "seed": 94, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = move v1;\n if (n) {\n while (n) {\n }\n }\n if (n) {\n if (n) {\n Take(v1);\n }\n }\n while (n) {\n release v1;\n Use(v2);\n }\n let v3 = move v1;\n return;\n}\n", + "diags": [ + [ + 14, + "OWN005" + ], + [ + 18, + "OWN003" + ], + [ + 21, + "OWN005" + ], + [ + 22, + "OWN001" + ], + [ + 22, + "OWN001" + ] + ] + }, + { + "seed": 95, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = move v1;\n let v4 = acquire R(1);\n release v1;\n Take(v3);\n Use(v2);\n return;\n}\n", + "diags": [ + [ + 12, + "OWN005" + ], + [ + 15, + "OWN001" + ], + [ + 15, + "OWN001" + ] + ] + }, + { + "seed": 96, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN001" + ], + [ + 10, + "OWN001" + ] + ] + }, + { + "seed": 97, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = move v2;\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ], + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 98, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Use(v1);\n while (n) {\n while (n) {\n use v1;\n release v1;\n }\n use v1;\n }\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN009" + ], + [ + 11, + "OWN003" + ], + [ + 13, + "OWN009" + ], + [ + 17, + "OWN001" + ], + [ + 17, + "OWN001" + ], + [ + 17, + "OWN001" + ] + ] + }, + { + "seed": 99, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n }\n let v1 = acquire R(1);\n let v2 = move v1;\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ], + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 100, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n while (n) {\n }\n }\n if (n) {\n if (n) {\n }\n while (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 101, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n while (n) {\n }\n }\n while (n) {\n }\n let v1 = acquire R(1);\n let v2 = move v1;\n return;\n}\n", + "diags": [ + [ + 14, + "OWN001" + ] + ] + }, + { + "seed": 102, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 103, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n while (n) {\n }\n if (n) {\n }\n }\n let v1 = acquire R(1);\n while (n) {\n release v1;\n }\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n release v1;\n use v3;\n return;\n}\n", + "diags": [ + [ + 14, + "OWN003" + ], + [ + 18, + "OWN003" + ], + [ + 20, + "OWN001" + ], + [ + 20, + "OWN001" + ] + ] + }, + { + "seed": 104, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 105, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = move v2;\n let v4 = move v1;\n Use(v2);\n let v5 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN005" + ], + [ + 12, + "OWN001" + ], + [ + 12, + "OWN001" + ], + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 106, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n }\n let v1 = acquire R(1);\n release v1;\n Take(v1);\n let v2 = move v1;\n while (n) {\n while (n) {\n release v1;\n }\n }\n Take(v2);\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 12, + "OWN002" + ], + [ + 13, + "OWN002" + ], + [ + 16, + "OWN003" + ], + [ + 21, + "OWN001" + ] + ] + }, + { + "seed": 107, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n if (n) {\n }\n let v1 = acquire R(1);\n let v2 = move v1;\n return;\n}\n", + "diags": [ + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 108, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n if (n) {\n Use(v1);\n }\n release v1;\n release v1;\n return;\n}\n", + "diags": [ + [ + 11, + "OWN003" + ] + ] + }, + { + "seed": 109, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n }\n while (n) {\n if (n) {\n }\n if (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 110, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n while (n) {\n }\n }\n if (n) {\n while (n) {\n }\n if (n) {\n }\n }\n while (n) {\n if (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 111, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n if (n) {\n while (n) {\n }\n }\n while (n) {\n while (n) {\n }\n }\n while (n) {\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 112, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n while (n) {\n }\n }\n while (n) {\n }\n while (n) {\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 113, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n release v1;\n use v1;\n }\n return;\n}\n", + "diags": [ + [ + 8, + "OWN003" + ], + [ + 9, + "OWN002" + ], + [ + 11, + "OWN001" + ] + ] + }, + { + "seed": 114, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Use(v1);\n use v1;\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN001" + ], + [ + 10, + "OWN001" + ] + ] + }, + { + "seed": 115, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n Take(v2);\n Use(v2);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN002" + ], + [ + 10, + "OWN001" + ] + ] + }, + { + "seed": 116, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n use v2;\n while (n) {\n }\n if (n) {\n Take(v1);\n use v2;\n if (n) {\n Use(v1);\n Use(v2);\n }\n }\n Take(v1);\n let v3 = acquire R(1);\n while (n) {\n release v3;\n Take(v3);\n while (n) {\n Take(v1);\n }\n }\n return;\n}\n", + "diags": [ + [ + 15, + "OWN002" + ], + [ + 19, + "OWN009" + ], + [ + 22, + "OWN009" + ], + [ + 23, + "OWN002" + ], + [ + 25, + "OWN002" + ], + [ + 28, + "OWN001" + ], + [ + 28, + "OWN001" + ] + ] + }, + { + "seed": 117, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 118, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Use(v1);\n let v2 = move v1;\n let v3 = acquire R(1);\n while (n) {\n if (n) {\n }\n release v3;\n while (n) {\n use v1;\n }\n }\n use v2;\n return;\n}\n", + "diags": [ + [ + 13, + "OWN003" + ], + [ + 15, + "OWN005" + ], + [ + 19, + "OWN001" + ], + [ + 19, + "OWN001" + ] + ] + }, + { + "seed": 119, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n if (n) {\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 120, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 121, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n }\n if (n) {\n while (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 122, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Take(v1);\n use v1;\n let v2 = move v1;\n release v2;\n return;\n}\n", + "diags": [ + [ + 8, + "OWN002" + ], + [ + 9, + "OWN002" + ] + ] + }, + { + "seed": 123, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n if (n) {\n use v1;\n Take(v1);\n }\n return;\n}\n", + "diags": [ + [ + 11, + "OWN001" + ] + ] + }, + { + "seed": 124, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n use v1;\n if (n) {\n use v1;\n }\n use v1;\n let v2 = move v1;\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 125, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n return;\n}\n", + "diags": [] + }, + { + "seed": 126, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n while (n) {\n Use(v1);\n }\n }\n use v1;\n while (n) {\n while (n) {\n release v1;\n }\n }\n release v1;\n if (n) {\n Take(v1);\n }\n while (n) {\n if (n) {\n }\n use v1;\n }\n return;\n}\n", + "diags": [ + [ + 15, + "OWN003" + ], + [ + 18, + "OWN003" + ], + [ + 20, + "OWN002" + ], + [ + 25, + "OWN002" + ] + ] + }, + { + "seed": 127, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 9, + "OWN001" + ], + [ + 9, + "OWN001" + ], + [ + 9, + "OWN001" + ] + ] + }, + { + "seed": 128, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n use v1;\n let v2 = move v1;\n let v3 = acquire R(1);\n let v4 = acquire R(1);\n let v5 = acquire R(1);\n let v6 = move v3;\n return;\n}\n", + "diags": [ + [ + 15, + "OWN001" + ], + [ + 15, + "OWN001" + ], + [ + 15, + "OWN001" + ], + [ + 15, + "OWN001" + ] + ] + }, + { + "seed": 129, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n if (n) {\n release v1;\n release v1;\n while (n) {\n release v1;\n }\n }\n release v1;\n while (n) {\n if (n) {\n release v1;\n }\n use v1;\n while (n) {\n use v1;\n use v1;\n }\n }\n use v1;\n release v1;\n return;\n}\n", + "diags": [ + [ + 9, + "OWN003" + ], + [ + 11, + "OWN003" + ], + [ + 14, + "OWN003" + ], + [ + 17, + "OWN003" + ], + [ + 19, + "OWN002" + ], + [ + 21, + "OWN002" + ], + [ + 22, + "OWN002" + ], + [ + 25, + "OWN002" + ], + [ + 26, + "OWN003" + ] + ] + }, + { + "seed": 130, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 11, + "OWN001" + ] + ] + }, + { + "seed": 131, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n if (n) {\n use v1;\n use v1;\n }\n let v2 = acquire R(1);\n use v1;\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ], + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 132, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n let v2 = move v1;\n let v3 = acquire R(1);\n release v2;\n return;\n}\n", + "diags": [ + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 133, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n if (n) {\n }\n }\n let v1 = acquire R(1);\n let v2 = move v1;\n let v3 = move v1;\n let v4 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 14, + "OWN005" + ], + [ + 16, + "OWN001" + ], + [ + 16, + "OWN001" + ], + [ + 16, + "OWN001" + ] + ] + }, + { + "seed": 134, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n Take(v1);\n }\n release v1;\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 8, + "OWN009" + ], + [ + 10, + "OWN009" + ], + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 135, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n while (n) {\n }\n }\n let v1 = acquire R(1);\n use v1;\n let v2 = move v1;\n Take(v2);\n let v3 = move v2;\n return;\n}\n", + "diags": [ + [ + 14, + "OWN002" + ], + [ + 15, + "OWN001" + ] + ] + }, + { + "seed": 136, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n if (n) {\n while (n) {\n }\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n release v1;\n let v3 = move v2;\n return;\n}\n", + "diags": [ + [ + 16, + "OWN001" + ] + ] + }, + { + "seed": 137, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n while (n) {\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 138, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 139, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n Take(v1);\n return;\n}\n", + "diags": [] + }, + { + "seed": 140, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = move v1;\n let v4 = acquire R(1);\n release v1;\n release v4;\n if (n) {\n use v2;\n release v2;\n }\n if (n) {\n Use(v3);\n if (n) {\n Use(v4);\n Take(v1);\n }\n if (n) {\n release v2;\n }\n }\n if (n) {\n if (n) {\n }\n if (n) {\n Take(v2);\n release v2;\n }\n if (n) {\n use v3;\n Use(v2);\n }\n }\n return;\n}\n", + "diags": [ + [ + 10, + "OWN005" + ], + [ + 19, + "OWN002" + ], + [ + 20, + "OWN002" + ], + [ + 23, + "OWN003" + ], + [ + 30, + "OWN009" + ], + [ + 31, + "OWN002" + ], + [ + 35, + "OWN009" + ], + [ + 38, + "OWN001" + ], + [ + 38, + "OWN001" + ] + ] + }, + { + "seed": 141, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n if (n) {\n Take(v1);\n }\n }\n let v2 = acquire R(1);\n Use(v2);\n let v3 = move v2;\n while (n) {\n release v1;\n while (n) {\n }\n release v3;\n }\n let v4 = move v2;\n return;\n}\n", + "diags": [ + [ + 9, + "OWN009" + ], + [ + 16, + "OWN003" + ], + [ + 19, + "OWN003" + ], + [ + 21, + "OWN005" + ], + [ + 22, + "OWN001" + ], + [ + 22, + "OWN001" + ], + [ + 22, + "OWN001" + ] + ] + }, + { + "seed": 142, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN001" + ], + [ + 10, + "OWN001" + ] + ] + }, + { + "seed": 143, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n release v1;\n release v1;\n return;\n}\n", + "diags": [ + [ + 9, + "OWN003" + ], + [ + 10, + "OWN001" + ] + ] + }, + { + "seed": 144, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n while (n) {\n while (n) {\n Take(v1);\n }\n }\n while (n) {\n release v1;\n while (n) {\n release v1;\n }\n use v1;\n }\n let v2 = acquire R(1);\n release v2;\n release v2;\n return;\n}\n", + "diags": [ + [ + 9, + "OWN009" + ], + [ + 13, + "OWN003" + ], + [ + 15, + "OWN003" + ], + [ + 17, + "OWN002" + ], + [ + 21, + "OWN003" + ], + [ + 22, + "OWN001" + ] + ] + }, + { + "seed": 145, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n while (n) {\n if (n) {\n }\n }\n while (n) {\n if (n) {\n }\n }\n if (n) {\n while (n) {\n }\n }\n while (n) {\n }\n let v1 = acquire R(1);\n release v1;\n return;\n}\n", + "diags": [] + }, + { + "seed": 146, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n use v1;\n return;\n}\n", + "diags": [ + [ + 8, + "OWN001" + ] + ] + }, + { + "seed": 147, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n while (n) {\n }\n }\n let v1 = acquire R(1);\n while (n) {\n use v1;\n }\n Use(v1);\n release v1;\n let v2 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 19, + "OWN001" + ] + ] + }, + { + "seed": 148, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n if (n) {\n }\n let v2 = acquire R(1);\n release v2;\n let v3 = acquire R(1);\n let v4 = move v3;\n return;\n}\n", + "diags": [ + [ + 13, + "OWN001" + ], + [ + 13, + "OWN001" + ] + ] + }, + { + "seed": 149, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = move v1;\n use v1;\n return;\n}\n", + "diags": [ + [ + 8, + "OWN005" + ], + [ + 9, + "OWN001" + ] + ] + }, + { + "seed": 150, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n while (n) {\n }\n if (n) {\n while (n) {\n }\n }\n while (n) {\n }\n while (n) {\n while (n) {\n }\n }\n return;\n}\n", + "diags": [] + }, + { + "seed": 151, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n while (n) {\n }\n if (n) {\n }\n }\n while (n) {\n }\n if (n) {\n while (n) {\n }\n }\n let v1 = acquire R(1);\n use v1;\n let v2 = move v1;\n use v1;\n Use(v2);\n return;\n}\n", + "diags": [ + [ + 21, + "OWN005" + ], + [ + 23, + "OWN001" + ] + ] + }, + { + "seed": 152, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = acquire R(1);\n let v4 = acquire R(1);\n let v5 = acquire R(1);\n Use(v4);\n while (n) {\n release v1;\n }\n let v6 = move v5;\n return;\n}\n", + "diags": [ + [ + 15, + "OWN003" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ] + ] + }, + { + "seed": 153, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n let v3 = move v2;\n Use(v2);\n Use(v3);\n release v1;\n return;\n}\n", + "diags": [ + [ + 9, + "OWN005" + ], + [ + 12, + "OWN001" + ] + ] + }, + { + "seed": 154, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n let v1 = acquire R(1);\n while (n) {\n Use(v1);\n while (n) {\n use v1;\n }\n }\n if (n) {\n Use(v1);\n }\n return;\n}\n", + "diags": [ + [ + 18, + "OWN001" + ] + ] + }, + { + "seed": 155, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n if (n) {\n }\n while (n) {\n }\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n use v1;\n release v1;\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 17, + "OWN001" + ], + [ + 17, + "OWN001" + ] + ] + }, + { + "seed": 156, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n if (n) {\n }\n while (n) {\n }\n let v1 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 11, + "OWN001" + ] + ] + }, + { + "seed": 157, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n }\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n Use(v2);\n Use(v1);\n use v1;\n let v3 = acquire R(1);\n return;\n}\n", + "diags": [ + [ + 14, + "OWN001" + ], + [ + 14, + "OWN001" + ], + [ + 14, + "OWN001" + ] + ] + }, + { + "seed": 158, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n let v1 = acquire R(1);\n let v2 = acquire R(1);\n if (n) {\n while (n) {\n use v1;\n }\n use v1;\n }\n let v3 = move v1;\n let v4 = acquire R(1);\n let v5 = acquire R(1);\n let v6 = move v1;\n return;\n}\n", + "diags": [ + [ + 17, + "OWN005" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ], + [ + 18, + "OWN001" + ] + ] + }, + { + "seed": 159, + "source": "module M\nresource R { acquire open release close }\nextern fn Use(borrow R);\nextern fn Take(consume R);\nfn gen(n: int) {\n while (n) {\n if (n) {\n }\n }\n let v1 = acquire R(1);\n while (n) {\n use v1;\n while (n) {\n release v1;\n Take(v1);\n }\n }\n let v2 = move v1;\n return;\n}\n", + "diags": [ + [ + 12, + "OWN009" + ], + [ + 14, + "OWN009" + ], + [ + 15, + "OWN002" + ], + [ + 18, + "OWN009" + ], + [ + 19, + "OWN001" + ] + ] + } + ] +} diff --git a/tests/fixtures/diag_parity.json b/tests/fixtures/diag_parity.json new file mode 100644 index 00000000..01fc954c --- /dev/null +++ b/tests/fixtures/diag_parity.json @@ -0,0 +1,677 @@ +{ + "comment": "GENERATED by tests/test_diag_fixtures.py --write; do not edit. Python (ownlang) is authoritative; rust/crates/own-analysis replays every case through the `check` surface and must match the ordered (line, code) verdict list exactly (issue #214, P-022 step 4).", + "cases": [ + { + "name": "corpus/real-world/ado-executereader-leak/case.own", + "source": "// OwnLang model of the canonical ADO.NET reader leak (P1a, ADO.NET tranche). A DbDataReader\n// from DbCommand.ExecuteReader() is a fresh owned IDisposable the caller must dispose; here it\n// is acquired, read, and never released — the generic OWN001 leak. The command is a borrowed\n// parameter and the reader does not escape (only a count is returned), so it stays tracked.\n// See notes.md for the recognition rule (return type implements System.Data.IDataReader).\nmodule Corpus\nresource Reader {\n acquire open\n release dispose\n kind \"disposable\"\n emit_type \"DbDataReader\"\n emit_acquire \"{args}.ExecuteReader()\"\n emit_release \"{0}.Dispose()\"\n}\nfn Run(cmd: int) {\n let reader = acquire Reader(cmd); // var reader = cmd.ExecuteReader()\n // rows read via reader.Read(); no `release reader;` — never disposed (OWN001)\n}\n", + "diags": [ + [ + 16, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/arraypool-aliased-receiver/case.own", + "source": "// OwnLang model: the same buffer returned twice is a double `release` -> OWN003. The C#\n// reduction reaches the pool through an aliased receiver (`ArrayPool p = ArrayPool<\n// int>.Shared`), which the extractor binds via the SemanticModel; the ownership logic is\n// identical to arraypool-double-return.\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n emit_type \"int[]\"\n emit_acquire \"p.Rent({args})\"\n emit_release \"p.Return({0})\"\n}\nfn run(n: int) {\n let rented = acquire Buffer(n); // p.Rent\n release rented; // p.Return\n release rented; // returned again -> OWN003\n}\n", + "diags": [ + [ + 16, + "OWN003" + ] + ] + }, + { + "name": "corpus/real-world/arraypool-double-return/case.own", + "source": "// OwnLang model: returning the same buffer twice is a double `release`.\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n emit_type \"byte[]\"\n emit_acquire \"ArrayPool.Shared.Rent({args})\"\n emit_release \"ArrayPool.Shared.Return({0})\"\n}\nfn run(n: int) {\n let rented = acquire Buffer(n); // ArrayPool.Rent\n release rented; // ArrayPool.Return\n release rented; // returned again -> OWN003\n}\n", + "diags": [ + [ + 13, + "OWN003" + ] + ] + }, + { + "name": "corpus/real-world/arraypool-field-fullspan-overread/case.own", + "source": "// OwnLang model of the ArrayPool full-length view over-read on a FIELD-backed pooled\n// buffer. `acquire` == ArrayPool.Rent into a field (the array is OVERSIZED:\n// Length >= n), `release` == ArrayPool.Return (here in Dispose, class-wide).\n// `overspan buf` models a FULL-length view `_buf.AsSpan()` (no length bound) taken\n// in a LATER member and read through — it reaches past the logical length n into the\n// stale [n, Length) tail. The bounded form `_buf.AsSpan(0, _n)` takes no full view,\n// so the fixed code has no `overspan` and is silent. `.own` has no fields/members, so\n// this single-function reduction pins the same core verdict the field pass produces;\n// the field-vs-local distinction lives in before.cs/after.cs (checked end to end by\n// the dotnet benchmark). The checker trips OWN025 (POOL005) at the view; the buffer\n// is still returned, so there is no leak (OWN001) and no use-after-return (OWN002).\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n kind \"pooled buffer\"\n}\nfn capture_then_flush(n: int) {\n let buf = acquire Buffer(n); // _buf = ArrayPool.Rent(n) (Length >= n)\n overspan buf; // _buf.AsSpan() in a later member — full view past n -> OWN025\n release buf; // ArrayPool.Return (Dispose)\n}\n", + "diags": [ + [ + 20, + "OWN025" + ] + ] + }, + { + "name": "corpus/real-world/arraypool-fullspan-overread/case.own", + "source": "// OwnLang model of the ArrayPool full-length view over-read. `acquire` ==\n// ArrayPool.Rent (the array is OVERSIZED: Length >= n), `release` ==\n// ArrayPool.Return. `overspan buf` models a FULL-length view `buf.AsSpan()` (no\n// length bound) handed to a consumer — it reaches past the logical length n into\n// the stale [n, Length) tail. The bounded form `buf.AsSpan(0, n)` takes no full\n// view, so the fixed code has no `overspan` and is silent. The checker trips\n// OWN025 (POOL005) at the view; the buffer is still correctly returned, so there\n// is no leak (OWN001) and no use-after-return (OWN002).\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n kind \"pooled buffer\"\n}\nfn frame(n: int) {\n let buf = acquire Buffer(n); // ArrayPool.Rent (Length >= n)\n overspan buf; // buf.AsSpan() — full-length view past n -> OWN025\n release buf; // ArrayPool.Return\n}\n", + "diags": [ + [ + 17, + "OWN025" + ] + ] + }, + { + "name": "corpus/real-world/arraypool-length-overread/case.own", + "source": "// OwnLang model of the POOL005 `.Length` over-read. `acquire` == ArrayPool.Rent\n// (oversized: Length >= n), `release` == ArrayPool.Return. `overspan buf` models a\n// view whose length is the buffer's OWN oversized `.Length` (`buf.AsSpan(0, buf.Length)`),\n// which spans the whole array — a consumer reads past the rented `n` into the stale\n// tail. Bounding by `n` takes no full view, so the fix has no `overspan` and is silent.\n// -> OWN025. The buffer is still returned, so there is no leak (OWN001).\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n kind \"pooled buffer\"\n}\nfn frame(n: int) {\n let buf = acquire Buffer(n); // ArrayPool.Rent (Length >= n)\n overspan buf; // buf.AsSpan(0, buf.Length) — spans the whole array -> OWN025\n release buf; // ArrayPool.Return\n}\n", + "diags": [ + [ + 15, + "OWN025" + ] + ] + }, + { + "name": "corpus/real-world/arraypool-memory-view-escape/case.own", + "source": "// OwnLang model. `acquire` == ArrayPool.Rent, `release` == ArrayPool.Return (run by the `finally`).\n// The C# returns a Memory VIEW of the buffer from inside a `try` whose `finally` returns the\n// buffer to the pool. A Memory — unlike a ref-struct Span — can leave the method, so the caller\n// receives the view AFTER the finally recycled the buffer: a dangling escape. The extractor models\n// the escaped view's use AFTER the finally release, so it reduces to a plain use-after-return\n// (OWN002), the same code reading the buffer directly after return would give.\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n}\nfn render(n: int) {\n let buf = acquire Buffer(n); // ArrayPool.Rent\n release buf; // ArrayPool.Return <-- the finally, before the caller sees the view\n use buf; // the escaped Memory view, used by the caller AFTER the finally -> OWN002\n}\n", + "diags": [ + [ + 15, + "OWN002" + ] + ] + }, + { + "name": "corpus/real-world/arraypool-span-view-after-return/case.own", + "source": "// OwnLang model. `acquire` == ArrayPool.Rent, `release` == ArrayPool.Return. The C# stores a Span\n// VIEW of the buffer in a local (`Span view = buf.AsSpan()`) and writes through it AFTER the\n// buffer was returned. A Span is a ref-struct BORROW of the buffer that cannot outlive the method,\n// so the extractor lowers a use of the view to a use of the OWNER buffer — the write after\n// `release` is the generic use-after-return (OWN002), the same code reading the buffer directly\n// would give. The borrow is resolved to its owner in the extractor; the core sees a plain\n// use-after-release.\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n}\nfn scramble(n: int) {\n let buf = acquire Buffer(n); // ArrayPool.Rent\n release buf; // ArrayPool.Return <-- too early\n use buf; // write through the Span view AFTER return -> OWN002\n}\n", + "diags": [ + [ + 16, + "OWN002" + ] + ] + }, + { + "name": "corpus/real-world/arraypool-use-after-return/case.own", + "source": "// OwnLang model. `acquire` == ArrayPool.Rent, `release` == ArrayPool.Return,\n// a `borrow`/call == reading a Span over the buffer. Reading after `release`\n// is the use-after-return the BEFORE code commits.\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n emit_type \"byte[]\"\n emit_acquire \"ArrayPool.Shared.Rent({args})\"\n emit_release \"ArrayPool.Shared.Return({0})\"\n emit_borrow \"{0}.AsSpan()\"\n}\nextern fn BuildResult(borrow Buffer);\nfn divide(n: int) {\n let quotient = acquire Buffer(n); // ArrayPool.Rent\n release quotient; // ArrayPool.Return <-- too early\n BuildResult(quotient); // read after return -> OWN002\n}\n", + "diags": [ + [ + 17, + "OWN002" + ] + ] + }, + { + "name": "corpus/real-world/arraypool-view-into-field-overread/case.own", + "source": "// OwnLang model of the ArrayPool full-length view over-read where the view is STORED\n// INTO A FIELD (P-007 POOL005; the \"into a field\" twin of arraypool-field-fullspan-\n// overread). `acquire` == ArrayPool.Rent into a field (the array is OVERSIZED:\n// Length >= n), `release` == ArrayPool.Return (in Dispose, class-wide). `overspan buf`\n// models the full-length view `_view = _buf.AsMemory()` cached into another field and\n// read through in a later member — it reaches past the logical length n into the stale\n// [n, Length) tail. The extractor's POOL005 field pass fires on the full-length view\n// EXPRESSION at the store, so the bug is caught where the unbounded view is\n// materialized; the bounded form `_buf.AsMemory(0, _n)` takes no full view, so the\n// fixed code has no `overspan` and is silent. `.own` has no fields/members, so this\n// single-function reduction pins the same core verdict the field pass produces; the\n// stored-into-a-field distinction lives in before.cs/after.cs (checked end to end by\n// the dotnet benchmark). The checker trips OWN025 (POOL005) at the view; the buffer is\n// still returned, so there is no leak (OWN001) and no use-after-return (OWN002).\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n kind \"pooled buffer\"\n}\nfn capture_then_flush(n: int) {\n let buf = acquire Buffer(n); // _buf = ArrayPool.Rent(n) (Length >= n)\n overspan buf; // _view = _buf.AsMemory() cached into a field -> OWN025\n release buf; // ArrayPool.Return (Dispose)\n}\n", + "diags": [ + [ + 23, + "OWN025" + ] + ] + }, + { + "name": "corpus/real-world/field-dispose-via-exchange/case.own", + "source": "// OwnLang model of NLog's TimeoutContinuation teardown (src/NLog/Internal/\n// TimeoutContinuation.cs, StopTimer). The owned Timer field is released by the atomic\n// detach-and-dispose idiom: `Interlocked.Exchange(ref _timeoutTimer, null)` hands back\n// the live timer and nulls the field, then `WaitForDispose(this Timer)` stops and\n// disposes it. before.cs omits the teardown — the generic OWN001 owned-field leak,\n// modelled here as an acquire with no `release`. after.cs releases it via the idiom,\n// which the extractor recognises by binding the exchange result to the field\n// (RefExchangeNulledField) and following the sink's dispose effect (CallReleasesReceiver).\nmodule Corpus\nresource Timer {\n acquire create\n release dispose\n kind \"disposable\"\n emit_type \"Timer\"\n emit_acquire \"new Timer({args})\"\n emit_release \"Interlocked.Exchange(ref {0}, null)?.WaitForDispose()\"\n}\nfn Continuation(callback: int) {\n let timer = acquire Timer(callback); // _timer = new Timer(_ => { }, null, 0, 1000)\n // no `release timer;` — the timer is never detached or disposed (OWN001)\n}\n", + "diags": [ + [ + 19, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/field-dispose-via-helper/case.own", + "source": "// OwnLang model of the \"drain and dispose\" field-release pattern mined on NLog\n// (src/NLog/Targets/AsyncTaskTarget.cs and siblings). A Timer field is owned by the\n// component; the real fix releases it not with a literal `_timer.Dispose()` but\n// through a custom extension method `WaitForDispose(this Timer, TimeSpan)` that\n// stops the timer and then disposes it (Common/AsyncHelpers.cs). The before.cs\n// leaks the timer (no release on any path) — the generic OWN001 owned-field leak\n// modelled here as an acquire with no `release`. The after.cs releases it via the\n// sink, which the extractor now recognises by inspecting the sink's body\n// (ConsumesParam on its receiver parameter); see notes.md for that recognition rule\n// and which NLog variants it does and does not reach.\nmodule Corpus\nresource Timer {\n acquire create\n release dispose\n kind \"disposable\"\n emit_type \"Timer\"\n emit_acquire \"new Timer({args})\"\n emit_release \"{0}.WaitForDispose()\"\n}\nfn Worker(callback: int) {\n let timer = acquire Timer(callback); // _timer = new Timer(_ => { }, null, 0, 1000)\n // no `release timer;` — the timer is never stopped or disposed (OWN001)\n}\n", + "diags": [ + [ + 21, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/field-noop-dispose-wrapper/case.own", + "source": "// OwnLang model of an owned BCL pass-through reader/writer field (StreamReader /\n// StreamWriter / BinaryReader / BinaryWriter) that is never disposed. Whether it leaks\n// depends on the BACKING it wraps: over a real file handle (before.cs) it is a genuine\n// OWN001 leak — modelled here as an acquire with no `release`. Over an in-memory\n// MemoryStream (after.cs) its Dispose is a no-op, which the extractor recognises\n// (IsNoOpDisposeWrapper) and stays silent on. See notes.md and\n// docs/notes/no-op-dispose-wrapper.md.\nmodule Corpus\nresource Reader {\n acquire create\n release dispose\n kind \"disposable\"\n emit_type \"StreamReader\"\n emit_acquire \"new StreamReader({args})\"\n emit_release \"{0}.Dispose()\"\n}\nfn FileLineReader(path: int) {\n let reader = acquire Reader(path); // _reader = new StreamReader(new FileStream(path))\n // before.cs: a real OS handle, never disposed -> OWN001\n}\n", + "diags": [ + [ + 18, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/local-dispose-via-using-statement/case.own", + "source": "// OwnLang model of a disposable acquired into a local that, in the fix, is released\n// through the STATEMENT form `using (existingLocal) { ... }` (protobuf-net assorted/\n// Silverlight Page.xaml.cs `timer`). The before.cs never wraps the local in a `using`,\n// so it leaks (OWN001) — modelled here as an acquire with no `release`. The after.cs\n// adds `using (r) { ... }`; the extractor now threads that scope-exit dispose onto the\n// tracked local (see notes.md), so the fixed form is silent.\nmodule Corpus\nresource Res {\n acquire create\n release dispose\n kind \"disposable\"\n emit_type \"Res\"\n emit_acquire \"new Res()\"\n emit_release \"{0}.Dispose()\"\n}\nfn Run() {\n let r = acquire Res(); // var r = new Res();\n // before.cs: never wrapped in `using (r)` -> OWN001\n}\n", + "diags": [ + [ + 17, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/memorypool-double-dispose/case.own", + "source": "// OwnLang model of the MemoryPool double-dispose. A MemoryPool owner is\n// `acquire`d by Rent and `release`d by Dispose (there is no Return). Disposing it\n// twice is a double release -> OWN003 — the same verdict as an ArrayPool\n// double-return; the pool can then hand the same memory to two renters at once.\nmodule Corpus\nresource MemoryOwner {\n acquire Rent\n release Dispose\n}\nfn run(n: int) {\n let owner = acquire MemoryOwner(n); // MemoryPool.Rent\n release owner; // owner.Dispose()\n release owner; // owner.Dispose() again -> OWN003\n}\n", + "diags": [ + [ + 13, + "OWN003" + ] + ] + }, + { + "name": "corpus/real-world/memorypool-using-owner-escape/case.own", + "source": "// OwnLang model of the MemoryPool bare-owner `using` escape. `acquire` == MemoryPool.Rent,\n// `release` == the implicit `using` scope-exit Dispose. The method returns the OWNER itself, but\n// the `using` disposes it FIRST (the extractor desugars `using owner = …; return owner;` to\n// `acquire; release; use` — the returned owner is read by the caller AFTER Dispose), so the caller\n// holds an `IMemoryOwner` already returned to the pool: a use-after-release lowered to OWN002. The\n// bare-owner twin of `memorypool-using-view-escape` (which returns `owner.Memory`, a view of it).\nmodule Corpus\nresource MemoryOwner {\n acquire Rent\n release Dispose\n}\nfn lease(n: int) {\n let owner = acquire MemoryOwner(n); // using MemoryPool.Rent\n release owner; // implicit `using` Dispose at scope exit\n use owner; // return owner — read by the caller AFTER Dispose -> OWN002\n}\n", + "diags": [ + [ + 15, + "OWN002" + ] + ] + }, + { + "name": "corpus/real-world/memorypool-using-statement-view-escape/case.own", + "source": "// OwnLang model of the MemoryPool `using`-STATEMENT view escape (the `using (...) { … }`\n// form of memorypool-using-view-escape). `acquire` == MemoryPool.Rent, `release` == the\n// implicit scope-exit Dispose of the `using` statement. The block returns `owner.Memory`\n// — a borrow of the owner — but the `using` disposes the owner FIRST, so the caller reads\n// a view of memory already returned to the pool: a use-after-release lowered to OWN002.\n// (The `.own` model is the same as the declaration form — the OwnLang reduction does not\n// distinguish the two C# `using` syntaxes; the extractor desugars both identically.)\nmodule Corpus\nresource MemoryOwner {\n acquire Rent\n release Dispose\n}\nfn lease(n: int) {\n let owner = acquire MemoryOwner(n); // using (... = MemoryPool.Rent)\n release owner; // implicit scope-exit Dispose\n use owner; // return owner.Memory — read by the caller AFTER Dispose -> OWN002\n}\n", + "diags": [ + [ + 16, + "OWN002" + ] + ] + }, + { + "name": "corpus/real-world/memorypool-using-view-escape/case.own", + "source": "// OwnLang model of the MemoryPool `using`-view escape. `acquire` == MemoryPool.Rent,\n// `release` == the implicit `using` scope-exit Dispose. The method returns `owner.Memory`\n// — a borrow of the owner — but the `using` disposes the owner FIRST (the extractor\n// desugars `using owner = …; return owner.Memory;` to `acquire; release; use`), so the\n// caller reads a view of memory already returned to the pool: a use-after-release lowered\n// to OWN002. (The using-dispose is threaded before the return's view-use, mirroring the\n// ArrayPool try/finally Memory escape.)\nmodule Corpus\nresource MemoryOwner {\n acquire Rent\n release Dispose\n}\nfn lease(n: int) {\n let owner = acquire MemoryOwner(n); // using MemoryPool.Rent\n release owner; // implicit `using` Dispose at scope exit\n use owner; // return owner.Memory — read by the caller AFTER Dispose -> OWN002\n}\n", + "diags": [ + [ + 16, + "OWN002" + ] + ] + }, + { + "name": "corpus/real-world/memorypool-view-after-dispose/case.own", + "source": "// OwnLang model of the MemoryPool view-after-dispose. `acquire` == MemoryPool.Rent,\n// `release` == owner.Dispose(). A `Memory` view (`owner.Memory`, and the `Span` from\n// it) is a BORROW of the owner — the extractor lowers a use of the view to a use of the\n// OWNER, so reading it after Dispose is the generic use-after-release (OWN002), the same\n// code reading the owner directly would give. The borrow is resolved to its owner in the\n// extractor; the core sees a plain use-after-release.\nmodule Corpus\nresource MemoryOwner {\n acquire Rent\n release Dispose\n}\nfn run(n: int) {\n let owner = acquire MemoryOwner(n); // MemoryPool.Rent\n release owner; // owner.Dispose() <-- too early\n use owner; // read the Memory view AFTER Dispose -> OWN002\n}\n", + "diags": [ + [ + 15, + "OWN002" + ] + ] + }, + { + "name": "corpus/real-world/nethermind-patriciatree-arraypool-leak/case.own", + "source": "// OwnLang model of NethermindEth/nethermind#9322 (fixed in Nethermind v1.35.0):\n// Nethermind.Trie/PatriciaTree.cs `Get` / `GetNodeByKey` Rent a byte[] from\n// ArrayPool for keys longer than the 64-byte stackalloc minimum, then call\n// `GetNew` (which can throw TrieException) and Return the buffer only on the\n// success path. When GetNew throws, the rented buffer leaks. The fix moves the\n// Return into a `finally` block.\n//\n// `acquire` == ArrayPool.Shared.Rent, `release` == .Return. The C# `throw`\n// is modeled as an early `return` arm that exits before the `release` — the path\n// that skips cleanup — so the core flags the un-released owned buffer as OWN001.\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n emit_type \"byte[]\"\n emit_acquire \"ArrayPool.Shared.Rent({args})\"\n emit_release \"ArrayPool.Shared.Return({0})\"\n}\nfn Get(keyLength: int, throws: int) {\n let array = acquire Buffer(keyLength); // ArrayPool.Rent (key > 64-byte stackalloc)\n if (throws) {\n return; // GetNew threw TrieException -> exits before Return\n }\n release array; // ArrayPool.Return -- only on the success path\n}\n", + "diags": [ + [ + 22, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/ownership-handoff-consume/case.own", + "source": "// OwnLang model of an inter-procedural ownership HANDOFF. `acquire`/`release`\n// == a Stream's open/Dispose. `archive` takes the stream BY VALUE (a resource\n// type => CONSUME): ownership MOVES into it and it closes the stream, so a\n// caller must not touch the stream after the handoff.\n//\n// This is the compositional island: `run`/`run_ok` are verified against\n// `archive`'s CONTRACT (consume), not its body -- the release obligation\n// travels across the call. No whole-program analysis: the signature is the cut.\nmodule Corpus\nresource Stream {\n acquire open\n release close\n}\n// first-party consumer: it OWNS the stream (CONSUME) and closes it.\nfn archive(s: Stream) {\n use s;\n release s;\n}\n// BUG: an owned stream that is neither closed nor handed to a consumer leaks.\nfn leak() {\n let s = acquire Stream();\n use s;\n}\n// BUG: the stream is used after ownership was moved into `archive`.\nfn run() {\n let s = acquire Stream();\n archive(s); // ownership consumed by archive (moves across the call)\n use s; // <-- touch after handoff\n}\n// happy path: same handoff, nothing touches the stream afterwards. The\n// obligation travelled to `archive`; `run_ok` neither uses nor releases it,\n// and that is correctly NOT a leak -- the contract discharged it.\nfn run_ok() {\n let s = acquire Stream();\n archive(s);\n}\n", + "diags": [ + [ + 22, + "OWN001" + ], + [ + 28, + "OWN002" + ] + ] + }, + { + "name": "corpus/real-world/ownership-handoff-use-transitive/case.own", + "source": "// OwnLang model of a TRANSITIVE inter-procedural use-after-handoff. `take` does not close the\n// stream itself -- it FORWARDS it to `inner`, which owns and closes it. So `take` consumes its\n// parameter TRANSITIVELY (the obligation travels one hop further), and `run` touching the\n// stream after `take(s)` is a use-after-handoff -> OWN002. The signature is still the cut: the\n// caller is checked against `take`'s (transitively inferred) contract, not its body -- no\n// whole-program analysis. (`consume` is an OwnLang keyword, so the reduction names the\n// consumers `take`/`inner`; the C# consumers are `Consume`/`Inner`.)\nmodule Corpus\nresource Stream {\n acquire open\n release close\n}\n// direct consumer: it OWNS the stream and closes it.\nfn inner(s: Stream) {\n use s;\n release s;\n}\n// transitive consumer: it forwards ownership to `inner` (does NOT close it itself), so it\n// consumes `s` one hop down the chain.\nfn take(s: Stream) {\n inner(s);\n}\n// BUG: the stream is used after ownership moved into `take` (-> inner, which closed it).\nfn run() {\n let s = acquire Stream();\n take(s); // ownership consumed transitively (take -> inner)\n use s; // <-- touched after handoff -> OWN002\n}\n", + "diags": [ + [ + 27, + "OWN002" + ] + ] + }, + { + "name": "corpus/real-world/ownership-handoff-use/case.own", + "source": "// OwnLang model of a PURE inter-procedural use-after-handoff (no leak arm). `take` takes\n// the stream BY VALUE (a resource type => CONSUME): ownership moves in and it closes the\n// stream, so a caller must not touch it after the handoff. `run` hands off then reads ->\n// OWN002 (use after the resource was consumed by the callee). The fix reads BEFORE the\n// handoff. The signature is the cut -- the caller is checked against `take`'s contract,\n// not its body; no whole-program analysis. (`consume` is an OwnLang keyword, so the\n// reduction names the consumer `take`; the C# consumer is `Consume`.)\nmodule Corpus\nresource Stream {\n acquire open\n release close\n}\nfn take(s: Stream) {\n use s;\n release s;\n}\nfn run() {\n let s = acquire Stream();\n take(s); // ownership moves into the consumer\n use s; // <-- touched after handoff -> OWN002\n}\n", + "diags": [ + [ + 20, + "OWN002" + ] + ] + }, + { + "name": "corpus/real-world/pool-transfer-via-wrapper-local/case.own", + "source": "// OwnLang model of a rented ArrayPool buffer that (in the fix) is transferred to a wrapper which\n// leaves the method through a local — StackExchange.Redis Lease.Create (`var lease = new\n// Lease(arr); return lease;`) and RedisServer scan (`var r = new ScanResult(keys); SetResult(m, r)`).\n// The before.cs wraps the buffer in a METHOD-SCOPED Holder that is dropped, so the buffer is never\n// returned -> a real leak, modelled here as an acquire with no `release` (OWN001). The after.cs\n// returns the wrapper, so the extractor follows the transfer through the escaping local and stays\n// silent (extractor-level; the before.cs/after.cs benchmark exercises it end to end). See notes.md.\nmodule Corpus\nresource Buffer {\n acquire rent\n release give\n emit_type \"int[]\"\n emit_acquire \"ArrayPool.Shared.Rent({args})\"\n emit_release \"ArrayPool.Shared.Return({0})\"\n}\nfn Run(n: int) {\n let buf = acquire Buffer(n); // ArrayPool.Rent; wrapped in a method-scoped Holder, never returned\n // no `release buf;` -> OWN001\n}\n", + "diags": [ + [ + 17, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/screentogif-loaded-subscription/case.own", + "source": "// OwnLang model of a real WPF view->view-model leak found by mining\n// NickeManarin/ScreenToGif (P-004 milestone 1). In VideoSource.xaml.cs the\n// Window's Loaded handler subscribes lambdas to its view-model's events and\n// never detaches them. A subscription is acquire/release (subscribe/unsubscribe);\n// the missing release is the generic OWN001 — see notes.md for the real finding,\n// its provenance, and why the C# extractor rates it a *warning* (injected source).\nmodule Corpus\nresource Subscription {\n acquire Subscribe\n release Unsubscribe\n kind \"subscription token\"\n}\nfn Window_Loaded(viewModel: int) {\n let showError = acquire Subscription(viewModel); // _viewModel.ShowErrorRequested += (_, e) => ...\n // no `release showError;` — Window_Closing never does `-=` -> handler leak (OWN001)\n}\n", + "diags": [ + [ + 14, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/screentogif-systemevents-leak/case.own", + "source": "// OwnLang model of a real WPF leak found by mining NickeManarin/ScreenToGif\n// (P-004 milestone 1). Microsoft.Win32.SystemEvents is a STATIC, process-lifetime\n// event source: a window that subscribes to DisplaySettingsChanged and never\n// unsubscribes is pinned alive for the whole process. Modelled as a subscription\n// acquire/release, the missing release is the generic OWN001 — and because the\n// source is static (provably outlives the window) the C# extractor rates it an\n// ERROR, not a warning. See notes.md for provenance and the tiering.\nmodule Corpus\nresource Subscription {\n acquire Subscribe\n release Unsubscribe\n kind \"subscription token\"\n}\nfn GraphicsConfigurationDialog(systemEvents: int) {\n let displaySettings = acquire Subscription(systemEvents); // SystemEvents.DisplaySettingsChanged += handler\n // no `release displaySettings;` — the static source pins the dialog forever (OWN001)\n}\n", + "diags": [ + [ + 15, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/sharex-rfc2898-derivebytes-leak/case.own", + "source": "// OwnLang model of a real disposable leak found by mining ShareX @ ed2a864\n// (ShareX.UploadersLib/FileUploaders/Vault_ooo.cs:216, DeriveCryptoData). An\n// Rfc2898DeriveBytes (the PBKDF2 deriver) is created on the upload path, used to\n// derive the AES Key + IV, and never disposed — it holds an HMAC, so the leak is real.\n// Modelled as a disposable acquire/release; the missing `release` is the generic\n// OWN001 leak. The deriver does not escape (only the derived byte[]s are returned),\n// so it stays tracked. See notes.md for provenance, the second (factory) leak the\n// extractor still misses, and the honesty caveat.\nmodule Corpus\nresource Deriver {\n acquire create\n release dispose\n kind \"disposable\"\n emit_type \"Rfc2898DeriveBytes\"\n emit_acquire \"new Rfc2898DeriveBytes({args})\"\n emit_release \"{0}.Dispose()\"\n}\nfn DeriveCryptoData(key: int) {\n let rfcDeriver = acquire Deriver(key); // new Rfc2898DeriveBytes(key, salt, 10000, SHA256)\n // derive Key + IV via rfcDeriver.GetBytes(...)\n // no `release rfcDeriver;` — the deriver is never disposed (OWN001)\n}\n", + "diags": [ + [ + 19, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/sharex-shapemanager-menuform-leak/case.own", + "source": "// OwnLang model of the ShareX ShapeManager.menuForm leak (issue #201 oracle\n// sweep): a class's Dispose() disposes some of its owned resources (`history`)\n// but forgets one it itself created (`menuForm`) — a plain owned-field leak,\n// the generic OWN001 acquire-with-no-release. See notes.md for the real finding.\nmodule Corpus\nresource Form {\n acquire create\n release dispose\n kind \"disposable\"\n}\nresource HistoryStack {\n acquire create\n release dispose\n kind \"disposable\"\n}\nfn Dispose(unused: int) {\n let menuForm = acquire Form(unused); // CreateToolbar(): menuForm = new Form { ... }\n let history = acquire HistoryStack(unused);\n release history; // Dispose(): history.Dispose()\n // no `release menuForm;` — Dispose() never touches menuForm (OWN001)\n}\n", + "diags": [ + [ + 19, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/socket-accept-leak/case.own", + "source": "// OwnLang model of the Socket.Accept() accept-loop leak (owned-API tranche). The accepted Socket\n// is a fresh owned IDisposable, used and never released -> OWN001. See notes.md.\nmodule Corpus\nresource Conn {\n acquire accept\n release dispose\n kind \"disposable\"\n emit_type \"Socket\"\n emit_acquire \"{args}.Accept()\"\n emit_release \"{0}.Dispose()\"\n}\nfn Serve(listener: int) {\n let conn = acquire Conn(listener); // var conn = listener.Accept()\n // conn used (conn.Connected); no `release conn;` -> OWN001\n}\n", + "diags": [ + [ + 13, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/subscription-self-owned-property/case.own", + "source": "// OwnLang model of the self-owned-property subscription discrimination, mined on\n// protobuf-net's CommandLineOptions: a `this`-capturing handler is subscribed to an\n// event on a source the component OWNS (a get-only property over a constructed field),\n// which is a collectable self-cycle, NOT a leak. before.cs is the leaky counterpart —\n// the same subscription on an INJECTED bus (external lifetime), never detached — the\n// generic OWN001 subscription leak, modelled here as a Subscription acquire with no\n// release. The extractor tells the two apart by resolving the event source: an owned\n// member (this/field/get-only-owned-property) is dropped (self-cycle), an injected one\n// stays a warning. See notes.md for the recognition rule (PropertyReturnsOwnedMember).\nmodule Corpus\nresource Subscription {\n acquire Subscribe\n release Unsubscribe\n kind \"subscription token\"\n}\nfn Watch(bus: int) {\n let token = acquire Subscription(bus); // _bus.Changed += (s, e) => _count++\n // no `release token;` — the injected bus is never `-=`'d -> handler leak (OWN001)\n}\n", + "diags": [ + [ + 17, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/tcplistener-accept-leak/case.own", + "source": "// OwnLang model of the canonical TCP accept-loop leak (owned-API tranche). A TcpClient from\n// TcpListener.AcceptTcpClient() is a fresh owned IDisposable the caller must dispose; here it is\n// accepted, used, and never released — the generic OWN001 leak. The listener is a borrowed\n// parameter and the client does not escape, so it stays tracked. Sibling accept members\n// (Socket.Accept, TcpListener.AcceptSocket) are recognised the same way. See notes.md.\nmodule Corpus\nresource Client {\n acquire accept\n release dispose\n kind \"disposable\"\n emit_type \"TcpClient\"\n emit_acquire \"{args}.AcceptTcpClient()\"\n emit_release \"{0}.Dispose()\"\n}\nfn Serve(listener: int) {\n let client = acquire Client(listener); // var client = listener.AcceptTcpClient()\n // client used (client.Connected); no `release client;` — never disposed (OWN001)\n}\n", + "diags": [ + [ + 16, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/tcplistener-acceptsocket-leak/case.own", + "source": "// OwnLang model of the TcpListener.AcceptSocket() accept-loop leak (owned-API tranche). The\n// accepted Socket is a fresh owned IDisposable, used and never released -> OWN001. See notes.md.\nmodule Corpus\nresource Sock {\n acquire accept\n release dispose\n kind \"disposable\"\n emit_type \"Socket\"\n emit_acquire \"{args}.AcceptSocket()\"\n emit_release \"{0}.Dispose()\"\n}\nfn Serve(listener: int) {\n let sock = acquire Sock(listener); // var sock = listener.AcceptSocket()\n // sock used (sock.Connected); no `release sock;` -> OWN001\n}\n", + "diags": [ + [ + 13, + "OWN001" + ] + ] + }, + { + "name": "corpus/real-world/using-statement-throw-releases/case.own", + "source": "// OwnLang model: a method with an explicit throw inside a `using (guard)` body must still\n// report an UNRELATED undisposed local (`conn`). `guard` is acquired and released (the using's\n// scope-exit dispose); `conn` is acquired and never released -> OWN001. The throw-routing\n// itself — release on the throw path, no whole-method bail — is an EXTRACTOR-level concern\n// (the onThrowDefinite flag) exercised end-to-end by before.cs/after.cs in the dotnet\n// benchmark; this pure-OwnLang reduction pins the ownership OUTCOME the extractor must produce:\n// guard balanced, conn leaked. See notes.md.\nmodule Corpus\nresource Guard {\n acquire create\n release dispose\n kind \"disposable\"\n emit_type \"Guard\"\n emit_acquire \"new Guard()\"\n emit_release \"{0}.Dispose()\"\n}\nresource Res {\n acquire create\n release dispose\n kind \"disposable\"\n emit_type \"Res\"\n emit_acquire \"new Res()\"\n emit_release \"{0}.Dispose()\"\n}\nfn Run(bad: int) {\n let guard = acquire Guard(); // var guard = new Guard();\n let conn = acquire Res(); // var conn = new Res(); never disposed -> OWN001\n release guard; // using (guard) { ... } disposes guard on every exit\n}\n", + "diags": [ + [ + 28, + "OWN001" + ] + ] + }, + { + "name": "corpus/wpf/field-use-after-dispose/case.own", + "source": "// OwnLang model of the field-mediated cross-method use-after-dispose the C#\n// extractor now lowers DIRECTLY (a disposed IDisposable field read in a subscribed\n// handler). `acquire` == the owned connection field's construction, `release` ==\n// its `Dispose()` in the ViewModel's Dispose(), `use` == a late dispatcher callback\n// (the subscribed handler) reading the connection AFTER it was disposed. Using a\n// disposable after its release is the generic OWN002, tagged with the resource kind.\n//\n// Contrast handler-use-after-dispose, whose handler reaches the disposed state\n// INDIRECTLY through a helper (`Refresh()`): the extractor only catches the DIRECT\n// `_field.Member` read, so that sibling case stays an honest extractor miss while\n// this one is caught end-to-end.\nmodule WpfFieldUseAfterDispose\n\n// The owned IDisposable field (a SqlConnection): acquired when the ViewModel\n// constructs it, released by Dispose(). `kind` tags the verdict as a disposable.\nresource Connection {\n acquire Open\n release Dispose\n kind \"disposable\"\n}\n\nfn OnDataChanged(bus: int) {\n let conn = acquire Connection(bus);\n release conn; // ViewModel.Dispose() disposes the owned connection\n use conn; // a late queued callback still touches it -> OWN002\n}\n", + "diags": [ + [ + 25, + "OWN002" + ] + ] + }, + { + "name": "corpus/wpf/handler-use-after-dispose-wrapped-delegate/case.own", + "source": "// OwnLang model of a use-after-dispose reached through a still-live event subscription written\n// with an explicit delegate creation. `acquire` == the owned connection's construction,\n// `release` == its Dispose(), `use` == a late `Changed` event (the subscribed handler) reading the\n// connection AFTER Dispose(). Using a disposable after its release is the generic OWN002, tagged\n// with the resource kind. The C# before/after carry the `+= new EventHandler(H)` syntax the\n// extractor must normalize to register the handler as live; this .own reduction only carries the\n// acquire/release/use logic the core reasons about.\nmodule WpfHandlerAfterDisposeWrappedDelegate\n\nresource Connection {\n acquire Open\n release Dispose\n kind \"disposable\"\n}\n\nfn OnSourceChanged(source: int) {\n let conn = acquire Connection(source);\n release conn; // View.Dispose() disposes the owned connection\n use conn; // a late Changed event reaches it after Dispose -> OWN002\n}\n", + "diags": [ + [ + 19, + "OWN002" + ] + ] + }, + { + "name": "corpus/wpf/handler-use-after-dispose/case.own", + "source": "// OwnLang model of the field-mediated use-after-dispose reached INDIRECTLY (through a helper).\n// `acquire` == the owned connection's construction, `release` == its Dispose() in the ViewModel's\n// Dispose(), `use` == a late dispatcher callback (the subscribed handler) reaching the connection\n// one hop down — via a private Refresh() helper — AFTER Dispose(). Using a disposable after its\n// release is the generic OWN002, tagged with the resource kind. The indirect twin of\n// `field-use-after-dispose` (a DIRECT field read); the extractor now chases the single helper hop.\nmodule WpfHandlerAfterDispose\n\nresource Connection {\n acquire Open\n release Dispose\n kind \"disposable\"\n}\n\nfn OnCustomerChanged(bus: int) {\n let conn = acquire Connection(bus);\n release conn; // ViewModel.Dispose() disposes the owned connection\n use conn; // a late callback reaches it via Refresh() after Dispose -> OWN002\n}\n", + "diags": [ + [ + 18, + "OWN002" + ] + ] + }, + { + "name": "corpus/wpf/pooled-view-after-dispose/case.own", + "source": "// OwnLang model of the pooled-buffer view-in-a-field dangle. `acquire` == MemoryPool.Rent (the\n// IMemoryOwner), `release` == its Dispose() in the type's Dispose() (the pooled buffer goes back to\n// the pool), `use` == a late dispatcher callback (the subscribed handler) reading the owner's Memory\n// VIEW — held in a field — AFTER Dispose. The view field aliases the owner, so reading it after the\n// owner's release is a use of the owner after release, lowered to OWN002. (The C# bridge tags the\n// synthetic flow with the generic `disposable` kind; here it is named `pooled buffer` to describe the\n// modelled resource.)\nmodule PooledViewAfterDispose\n\nresource PooledBuffer {\n acquire Rent\n release Dispose\n kind \"pooled buffer\"\n}\n\nfn OnFrameReady(bus: int) {\n let owner = acquire PooledBuffer(bus);\n release owner; // the type's Dispose() returns the pooled buffer\n use owner; // a late callback reads the owner's Memory view (a field) after Dispose -> OWN002\n}\n", + "diags": [ + [ + 19, + "OWN002" + ] + ] + }, + { + "name": "corpus/wpf/subscription-explicit-delegate-release/case.own", + "source": "module WpfSubscriptionExplicitDelegateRelease\n\n// A subscription token: `+= handler` acquires the source<->listener edge; the\n// matching `-= handler` releases it. `kind` tags the resource so the generic\n// ownership finding carries a [resource: ...] note.\nresource Subscription {\n acquire Subscribe\n release Dispose\n kind \"subscription token\"\n}\n\n// The view modelled as one scope: the ctor subscribes to the injected source and\n// (in before.cs) never unsubscribes — no Dispose. Acquiring the token without\n// releasing it = the injected source keeps the listener alive => OWN001.\n//\n// The C# after.cs releases it in Dispose — but writes the `-=` as a bare method\n// group while\n// the `+=` wraps the handler in `new PropertyChangedEventHandler(...)`. That\n// syntactic asymmetry is what the extractor must normalize; this .own reduction\n// only carries the acquire/release logic the core reasons about.\nfn SourceView(source: int) {\n let sub = acquire Subscription(source);\n // no `release sub;` -> unreleased subscription (before.cs)\n}\n", + "diags": [ + [ + 22, + "OWN001" + ] + ] + }, + { + "name": "corpus/wpf/subscription-target-typed-delegate-release/case.own", + "source": "module WpfSubscriptionTargetTypedDelegateRelease\n\n// A subscription token: `+= handler` acquires the source<->listener edge; the\n// matching `-= handler` releases it. `kind` tags the resource so the finding\n// carries a [resource: ...] note.\nresource Subscription {\n acquire Subscribe\n release Dispose\n kind \"subscription token\"\n}\n\n// The view modelled as one scope: the ctor subscribes to the injected source and\n// (in before.cs) never unsubscribes — no Dispose => OWN001.\n//\n// The C# after.cs releases it in Dispose, but the ctor `+=` uses C# 9 target-typed\n// delegate creation `new(H)` while the `-=` is a bare method group. That syntactic\n// asymmetry is what the extractor must normalize; this .own reduction only carries\n// the acquire/release logic the core reasons about.\nfn SourceView(source: int) {\n let sub = acquire Subscription(source);\n // no `release sub;` -> unreleased subscription (before.cs)\n}\n", + "diags": [ + [ + 20, + "OWN001" + ] + ] + }, + { + "name": "corpus/wpf/systemevents-region-escape/case.own", + "source": "module SystemEventsRegionEscape\n\n// Lifetime regions. The static `Microsoft.Win32.SystemEvents` class lives for the\n// whole process; a WPF window/dialog that subscribes to it is strictly\n// shorter-lived. This `<` order is exactly what the C# bridge gives a `capture`\n// OwnIR fact whose source is a `static` (process-lived) event — see\n// ownlang/ownir.py (`Subscriber < Process`); the corpus uses the WPF-authentic\n// name `Window` for the short region.\nlifetime Process; // Microsoft.Win32.SystemEvents — a static, process-lived source\nlifetime Window < Process; // the dialog/window — strictly shorter-lived\n\n// The window strongly subscribes itself to the process-lived\n// SystemEvents.DisplaySettingsChanged static event and keeps no unsubscribe\n// token. Because Process strictly outlives Window, the strong delegate promotes\n// the window to process lifetime -> it can never be collected while the app runs\n// => OWN014. This is the SAME real bug as\n// corpus/real-world/screentogif-systemevents-leak, seen through the REGION model\n// (escape) rather than the token model (OWN001) — and the precise shape the\n// extractor lowers a static-event `+=` to (the `capture` fact, P-004 WPF005).\nfn GraphicsConfigurationDialog(systemEvents: SystemEvents lifetime Process) lifetime Window {\n subscribe self to systemEvents;\n}\n", + "diags": [ + [ + 21, + "OWN014" + ] + ] + }, + { + "name": "corpus/wpf/viewmodel-escapes-to-app/case.own", + "source": "module WpfRegionEscape\n\n// Lifetime regions: a Window-lived ViewModel must not outlive its window, and\n// the App-lived event bus outlives everything.\nlifetime App;\nlifetime Window < App;\nlifetime ViewModel < Window;\n\n// The ViewModel (ViewModel-lived) strongly subscribes itself to the App-lived\n// bus. Because App strictly outlives ViewModel, the subscription promotes the\n// VM to App lifetime -> it can never die while the app runs => OWN014. This is\n// the region-escape theorem: the *ordering* is what makes it a leak (subscribing\n// to a same/shorter-lived source would be fine).\nfn CustomerViewModel(bus: EventBus lifetime App) lifetime ViewModel {\n subscribe self to bus;\n}\n", + "diags": [ + [ + 15, + "OWN014" + ] + ] + }, + { + "name": "corpus/wpf/zombie-viewmodel/case.own", + "source": "module WpfZombieViewModel\n\n// A subscription token: bus.Subscribe(handler) hands you one; you must\n// Dispose it to unsubscribe. `kind` tags this resource so the generic\n// ownership finding carries a business-flavoured [resource: ...] note.\nresource Subscription {\n acquire Subscribe\n release Dispose\n kind \"subscription token\"\n}\n\n// The ViewModel modelled as one scope: its \"constructor\" subscribes to the\n// App-lifetime bus; the end of the scope is its Dispose. Acquiring the token\n// without releasing it = the bus keeps the VM alive => leak (OWN001).\nfn CustomerViewModel(bus: int) {\n let customerChanged = acquire Subscription(bus);\n // no `release customerChanged;` -> zombie ViewModel\n}\n", + "diags": [ + [ + 16, + "OWN001" + ] + ] + }, + { + "name": "examples/bad_leak_branch.own", + "source": "module LeakBranchDemo\n\nresource Conn {\n acquire open\n release close\n}\n\n// OWN001: released on the 'then' path only; leaks through 'else'.\nfn maybe(flag: int) {\n let c = acquire Conn(flag);\n if (flag) {\n release c;\n }\n}\n", + "diags": [ + [ + 10, + "OWN001" + ] + ] + }, + { + "name": "examples/bad_maybe_release.own", + "source": "module MaybeReleaseDemo\n\nresource Buffer {\n acquire rent\n release give\n}\n\n// 'b' is released on the 'then' path only. At the join its state is\n// inconsistent, so the following `use b` touches a maybe-released value\n// (OWN009). The 'else' path never releases, so it also leaks (OWN001).\n// Good for `python -m ownlang cfg examples/bad_maybe_release.own`.\nfn use_after_maybe(flag: int) {\n let b = acquire Buffer(flag);\n if (flag) {\n release b;\n }\n use b;\n}\n", + "diags": [ + [ + 17, + "OWN001" + ], + [ + 17, + "OWN009" + ] + ] + }, + { + "name": "examples/bad_scratch_escape.own", + "source": "module BadScratchEscape\n\n// OWN015: a scratch buffer may be stack-backed, so returning it would hand back\n// a span into a frame that is about to pop. If you need to return a buffer, use\n// Buffer.pooled(...) — a movable owned resource whose Return the checker enforces.\n\nfn leak(size: int) -> Buffer {\n let tmp = Buffer.scratch(size, inline = 1024);\n return tmp;\n}\n", + "diags": [ + [ + 9, + "OWN015" + ] + ] + }, + { + "name": "examples/buffer_scratch.own", + "source": "module ScratchDemo\n\n// A scratch buffer: prefer the stack, fall back to ArrayPool when the request\n// exceeds the inline limit. The user states the intent; the checker proves it\n// stays local; codegen emits both branches; and OwnTrace/OwnCounters log which\n// backend was actually chosen at runtime.\n//\n// python -m ownlang check buffer_scratch.own # ownership is clean\n// python -m ownlang emit buffer_scratch.own # stack-first + pool fallback\n// python -m ownlang report buffer_scratch.own # what the compiler decided\n\npolicy DefaultScratch {\n inline_bytes = 1024;\n fallback = pool;\n trace = debug;\n counters = true;\n clear_on_release = false;\n}\n\nextern fn Fill(borrow_mut Buffer);\nextern fn Hash(borrow Buffer);\n\nfn parse(size: int) {\n let tmp = Buffer.scratch(size, inline = 1024, fallback = pool);\n borrow_mut tmp as bytes {\n Fill(bytes);\n }\n borrow tmp as view {\n Hash(view);\n }\n release tmp;\n}\n", + "diags": [] + }, + { + "name": "examples/buffer_stack.own", + "source": "module StackDemo\n\n// Strict stack mode: stackalloc only, the heap fallback is FORBIDDEN, the buffer\n// cannot escape. A constant bound lowers to `stackalloc byte[N]`; a dynamic size\n// requires a `max =` guard so the real stack frame is bounded — \"we put up a\n// fence\", not \"the user promised\".\n\nextern fn Fill(borrow_mut Buffer);\n\nfn fixed_buf() {\n let line = Buffer.stack(256); // -> Span line = stackalloc byte[256];\n borrow_mut line as m {\n Fill(m);\n }\n release line;\n}\n\nfn bounded(size: int)\n{\n // dynamic size, but bounded: codegen emits a guard then slices the backing.\n let buf = Buffer.stack(size, max = 1024);\n borrow_mut buf as m {\n Fill(m);\n }\n release buf;\n}\n", + "diags": [] + }, + { + "name": "examples/gallery/00_ok_clean.own", + "source": "// Clean: acquire, look at it mutably then read-only, hand it back.\n// Lowers to ArrayPool Rent/Return inside a try/finally (exception-safe).\nmodule Gallery\nresource Buffer {\n acquire rent\n release give\n emit_type \"byte[]\"\n emit_acquire \"ArrayPool.Shared.Rent({args})\"\n emit_release \"ArrayPool.Shared.Return({0})\"\n emit_borrow \"{0}.AsSpan()\"\n}\nextern fn Fill(borrow_mut Buffer);\nextern fn Hash(borrow Buffer);\nfn process(size: int) {\n let buf = acquire Buffer(size);\n borrow_mut buf as bytes { Fill(bytes); }\n borrow buf as view { Hash(view); }\n release buf;\n}\n", + "diags": [] + }, + { + "name": "examples/gallery/01_leak_on_error_path.own", + "source": "// OWN001 — leak on one path.\n// Real C#: you Dispose() in the happy branch but forget it on the early-out.\nmodule Gallery\nresource Conn { acquire open release close }\nfn handle(flag: int) {\n let c = acquire Conn(flag);\n if (flag) {\n release c; // released here ...\n } // ... but on the else path c is never closed -> leak\n}\n", + "diags": [ + [ + 6, + "OWN001" + ] + ] + }, + { + "name": "examples/gallery/02_use_after_release.own", + "source": "// OWN002 — use after release (definite, on every path).\n// Real C#: touching a stream after Dispose() -> ObjectDisposedException.\nmodule Gallery\nresource Conn { acquire open release close }\nextern fn Send(borrow Conn);\nfn run() {\n let c = acquire Conn(1);\n release c;\n Send(c); // c is already closed\n}\n", + "diags": [ + [ + 9, + "OWN002" + ] + ] + }, + { + "name": "examples/gallery/03_double_release.own", + "source": "// OWN003 — double release.\n// Real C#: Dispose() called twice (and the type isn't idempotent).\nmodule Gallery\nresource Conn { acquire open release close }\nfn run() {\n let c = acquire Conn(1);\n release c;\n release c;\n}\n", + "diags": [ + [ + 8, + "OWN003" + ] + ] + }, + { + "name": "examples/gallery/04_use_after_move.own", + "source": "// OWN005 — use after ownership was moved away.\n// Real C#: using a buffer after you handed the only owner to someone else.\nmodule Gallery\nresource Buffer { acquire rent release give }\nextern fn Hash(borrow Buffer);\nfn run() {\n let a = acquire Buffer(64);\n let b = move a; // ownership transferred to b\n Hash(a); // a is now empty\n release b;\n}\n", + "diags": [ + [ + 9, + "OWN005" + ] + ] + }, + { + "name": "examples/gallery/05_dispose_while_view_live.own", + "source": "// OWN008 — release while a borrow (a view) is still outstanding.\n// Real C#: returning the array to ArrayPool while a Span over it is in use.\nmodule Gallery\nresource Buffer { acquire rent release give }\nextern fn Fill(borrow_mut Buffer);\nfn run() {\n let b = acquire Buffer(64);\n borrow_mut b as view {\n release b; // freeing the backing store while `view` is alive\n Fill(view);\n }\n}\n", + "diags": [ + [ + 9, + "OWN008" + ] + ] + }, + { + "name": "examples/gallery/06_exclusive_while_shared.own", + "source": "// OWN006 — exclusive (mutable) access while a shared view is live.\n// Real C#: writing through one Span while another ReadOnlySpan aliases it.\nmodule Gallery\nresource Buffer { acquire rent release give }\nextern fn Fill(borrow_mut Buffer);\nfn run() {\n let b = acquire Buffer(64);\n borrow b as readers {\n Fill(b); // needs exclusive access, but `readers` is live\n }\n release b;\n}\n", + "diags": [ + [ + 9, + "OWN006" + ] + ] + }, + { + "name": "examples/gallery/07_use_after_handoff.own", + "source": "// OWN002 — use after ownership was consumed by a callee.\n// Real C#: a method takes ownership (it will Dispose), then you touch it again.\nmodule Gallery\nresource Buffer { acquire rent release give }\nextern fn Store(consume Buffer);\nextern fn Hash(borrow Buffer);\nfn run() {\n let b = acquire Buffer(64);\n Store(b); // Store now owns b (and will free it)\n Hash(b); // ... but we used it afterwards\n}\n", + "diags": [ + [ + 10, + "OWN002" + ] + ] + }, + { + "name": "examples/gallery/08_stack_buffer_escapes.own", + "source": "// OWN015 — a stack-backed buffer cannot escape its function.\n// Real C#: returning a Span over a stackalloc -> it dangles the instant\n// the frame pops.\nmodule Gallery\nfn make() -> Buffer {\n let tmp = Buffer.stack(64); // lives on the stack frame of make()\n return tmp; // ... must not leave it\n}\n", + "diags": [ + [ + 7, + "OWN015" + ] + ] + }, + { + "name": "examples/gallery/09_untracked_call.own", + "source": "// OWN040 — an unknown call is a hole the size of a bus, so it is forbidden.\n// Real C#: ownership \"laundered\" through an opaque helper the checker can't see.\nmodule Gallery\nresource Buffer { acquire rent release give }\nfn run() {\n let b = acquire Buffer(64);\n Mystery(b); // not declared as extern fn / local fn -> rejected\n release b;\n}\n", + "diags": [ + [ + 7, + "OWN040" + ] + ] + }, + { + "name": "examples/gallery/10_leak_in_loop.own", + "source": "// OWN001 — a resource acquired every iteration but never released: leaks each pass.\n// Real C#: `while (cond) { var conn = Open(...); Use(conn); }` with no Dispose() —\n// a fresh handle leaks on every loop turn, regardless of what ends the loop. The\n// condition is opaque to the checker (control flow is modelled, not values); what\n// matters is the back-edge, which the worklist fixpoint analyses rather than skips.\nmodule Gallery\nresource Conn { acquire open release close }\nfn drain(n: int) {\n while (n) {\n let c = acquire Conn(n); // opened every iteration ...\n use c;\n } // ... never closed -> leak\n}\n", + "diags": [ + [ + 10, + "OWN001" + ] + ] + }, + { + "name": "examples/gallery/11_overspan_full_view.own", + "source": "// POOL005 — a full-length Span view of a pooled buffer reaches past its logical\n// length. `ArrayPool.Rent(n)` hands back an OVERSIZED array (Length >= n); a view\n// with no length bound (`buf.AsSpan()`) spans the whole array, so reading or\n// copying through it exposes the stale [n, Length) tail a previous renter left.\n// Real C#: `Emit(buf.AsSpan())` where `Emit(buf.AsSpan(0, n))` was meant.\nmodule Gallery\nresource Buffer {\n acquire rent\n release give\n kind \"pooled buffer\"\n}\nfn frame(n: int) {\n let buf = acquire Buffer(n);\n overspan buf; // full-length view -> OWN025\n release buf;\n}\n", + "diags": [ + [ + 14, + "OWN025" + ] + ] + }, + { + "name": "examples/golden_arraypool/buffer.own", + "source": "module PoolDemo\n\n// A pooled byte buffer mapped onto System.Buffers.ArrayPool.\n// The emit_* templates make the lowering REAL .NET, not schematic:\n// acquire -> Rent, release -> Return, a borrow -> a Span view.\nresource Buffer {\n acquire rent\n release give\n emit_type \"byte[]\"\n emit_acquire \"ArrayPool.Shared.Rent({args})\"\n emit_release \"ArrayPool.Shared.Return({0})\"\n emit_borrow \"{0}.AsSpan()\"\n}\n\n// Host promises. A borrow parameter is noescape: the callee must not retain\n// the span past the call. The only way a value may escape is `consume`.\nextern fn Fill(borrow_mut Buffer);\nextern fn Hash(borrow Buffer);\n\nfn process(size: int) {\n let buf = acquire Buffer(size);\n borrow_mut buf as bytes {\n Fill(bytes);\n }\n borrow buf as view {\n Hash(view);\n }\n release buf;\n}\n", + "diags": [] + }, + { + "name": "examples/ok_extern_calls.own", + "source": "module ExternCalls\n\nresource Buffer {\n acquire rent\n release give\n}\n\n// Host (C#) calls must declare what they do with ownership. The checker\n// trusts these effect annotations and nothing else: an undeclared call that\n// is handed an owned value is a hard OWN040. Borrow params are noescape, so\n// the value cannot leak out through them.\nextern fn Fill(borrow_mut Buffer); // writes through the buffer, hands it back\nextern fn Hash(borrow Buffer); // reads only, hands it back\nextern fn Store(consume Buffer); // takes ownership; caller must not reuse\n\n// Happy path: lend the buffer to the host mutably, then immutably, then hand\n// it back ourselves. Both extern calls borrow, so 'b' is still owned after.\nfn read_write(size: int) {\n let b = acquire Buffer(size);\n Fill(b);\n Hash(b);\n release b;\n}\n\n// Happy path: give the buffer to the host for good. Store consumes it, so we\n// neither release nor use 'b' afterwards — either of those would be OWN002.\nfn hand_off(size: int) {\n let b = acquire Buffer(size);\n Store(b);\n}\n", + "diags": [] + }, + { + "name": "examples/ok_pool.own", + "source": "module PoolDemo\n\nresource Buffer {\n acquire rent\n release give\n}\n\n// Canonical happy path: rent a buffer, look at it mutably then read it,\n// hand it back. Checker proves it is returned on the single path; codegen\n// hoists the release into a finally for exception-safety.\nfn process(size: int) {\n let buf = acquire Buffer(size);\n\n borrow_mut buf as bytes {\n use bytes;\n }\n\n borrow buf as bytes {\n use bytes;\n }\n\n release buf;\n}\n", + "diags": [] + }, + { + "name": "curated_leak_and_release", + "source": "module M\nresource Conn { acquire open release close }\nresource Token { acquire mint release burn kind \"subscription token\" }\nextern fn Fill(borrow_mut Conn);\nextern fn Hash(borrow Conn);\nextern fn Store(consume Conn);\nfn leaks() {\n let c = acquire Conn(1);\n return;\n}\nfn clean() {\n let c = acquire Conn(1);\n release c;\n return;\n}\n", + "diags": [ + [ + 9, + "OWN001" + ] + ] + }, + { + "name": "curated_use_after_release", + "source": "module M\nresource Conn { acquire open release close }\nresource Token { acquire mint release burn kind \"subscription token\" }\nextern fn Fill(borrow_mut Conn);\nextern fn Hash(borrow Conn);\nextern fn Store(consume Conn);\nfn f() {\n let c = acquire Conn(1);\n release c;\n Hash(c);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN002" + ] + ] + }, + { + "name": "curated_double_release", + "source": "module M\nresource Conn { acquire open release close }\nresource Token { acquire mint release burn kind \"subscription token\" }\nextern fn Fill(borrow_mut Conn);\nextern fn Hash(borrow Conn);\nextern fn Store(consume Conn);\nfn f() {\n let c = acquire Conn(1);\n release c;\n release c;\n return;\n}\n", + "diags": [ + [ + 10, + "OWN003" + ] + ] + }, + { + "name": "curated_use_after_move", + "source": "module M\nresource Conn { acquire open release close }\nresource Token { acquire mint release burn kind \"subscription token\" }\nextern fn Fill(borrow_mut Conn);\nextern fn Hash(borrow Conn);\nextern fn Store(consume Conn);\nfn f() {\n let c = acquire Conn(1);\n let d = move c;\n Hash(c);\n Store(d);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN005" + ] + ] + }, + { + "name": "curated_maybe_release_branch", + "source": "module M\nresource Conn { acquire open release close }\nresource Token { acquire mint release burn kind \"subscription token\" }\nextern fn Fill(borrow_mut Conn);\nextern fn Hash(borrow Conn);\nextern fn Store(consume Conn);\nfn f(n: int) {\n let c = acquire Conn(1);\n if (n) { release c; }\n Hash(c);\n return;\n}\n", + "diags": [ + [ + 10, + "OWN009" + ], + [ + 11, + "OWN001" + ] + ] + }, + { + "name": "curated_buffer_policy", + "source": "module M\nfn f(n: int) {\n let a = Buffer.inline(999999);\n release a;\n}\n", + "diags": [ + [ + 3, + "OWN019" + ] + ] + }, + { + "name": "curated_parse_error_is_own020", + "source": "module M\nfn f( {\n", + "diags": [ + [ + 2, + "OWN020" + ] + ] + } + ] +} diff --git a/tests/test_di_eff_fact_parity.py b/tests/test_di_eff_fact_parity.py new file mode 100644 index 00000000..74826eef --- /dev/null +++ b/tests/test_di_eff_fact_parity.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Fact-level differential parity for the effect (EFF001) and DI (DI001-005) +analyses (P-022 step 4, issue #214). + +These two families are OwnIR-fact sidecar analyses — the bridge feeds them facts, +so there is no `.own` surface. Python remains the reference: this generator +builds normalized effect/DI **fact inputs** and freezes the expected +`(path, line, code)` verdicts (computed by the *real* `ownlang.effects` / +`ownlang.di` finders plus the bridge's primary-anchor selection) into +`tests/fixtures/di_eff_fact_parity.json`. The Rust side +(`rust/crates/own-analysis/tests/fact_parity.rs`) deserializes the same facts, +runs its ported analyses, and must reproduce the exact ordered verdict list with +**zero Python**. + +The primary anchor matches the bridge (`ownlang/ownir.py`): + +* DI001/DI002/DI003 → the singleton **registration** site; +* DI004 → the **root-resolution call site** of the entry type (registration + fallback when unknown) — `_di004_primary`; +* DI005 → the **field-store site** of the cached entry (registration fallback) + — `_di005_primary`; +* EFF001 → the effect's own `(file, line)`. + +Run: python tests/test_di_eff_fact_parity.py (verify) + python tests/test_di_eff_fact_parity.py --write (regenerate) + python tests/run_tests.py (runs it as part of 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.di import ( + Service, + find_captive_dependencies, + find_captured_transient_disposables, + find_explicit_root_resolutions, + find_scope_cached_captives, + find_weak_captive_dependencies, +) +from ownlang.effects import Binding, Effect, find_effect_storms + +FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "di_eff_fact_parity.json") + + +# ---- effect fact cases ------------------------------------------------------- + +def _effect_from_json(e: dict) -> Effect: + return Effect( + component=e["component"], + deps=tuple(e["deps"]), + io=e["io"], + bindings=tuple( + Binding(name=b["name"], init=b["init"], refs=tuple(b.get("refs", [])), + line=b.get("line", 0)) + for b in e.get("bindings", []) + ), + file=e.get("file", "?"), + line=e.get("line", 0), + ) + + +def _bind(name: str, init: str, refs: list[str] | None = None, line: int = 1) -> dict: + return {"name": name, "init": init, "refs": refs or [], "line": line} + + +def _eff(component: str, deps: list[str], io: bool, bindings: list[dict], + file: str, line: int) -> dict: + return {"component": component, "deps": deps, "io": io, + "bindings": bindings, "file": file, "line": line} + + +_EFFECT_CASES: list[tuple[str, list[dict]]] = [ + ("fresh_object_storm", + [_eff("A", ["opts"], True, [_bind("opts", "object")], "A.tsx", 10)]), + ("memoised_clean", + [_eff("A", ["opts"], True, [_bind("opts", "memo")], "A.tsx", 10)]), + ("no_io_clean", + [_eff("A", ["opts"], False, [_bind("opts", "object")], "A.tsx", 10)]), + ("derivation_chain", + [_eff("A", ["c"], True, + [_bind("a", "object"), _bind("b", "ident", ["a"]), + _bind("c", "ident", ["b"])], "A.tsx", 12)]), + ("opaque_call_unknown", + [_eff("A", ["x"], True, [_bind("x", "call")], "A.tsx", 10)]), + ("plain_identifier_stable", + [_eff("A", ["props.id"], True, [], "A.tsx", 10)]), + ("identity_cycle_safe", + [_eff("A", ["a"], True, + [_bind("a", "ident", ["b"]), _bind("b", "ident", ["a"])], "A.tsx", 10)]), + # multi-file ordering + equal lines: two storms on the SAME line in DIFFERENT + # files must sort by file first (b.tsx before z.tsx). + ("multi_file_equal_lines", + [_eff("Z", ["o"], True, [_bind("o", "object")], "z.tsx", 7), + _eff("B", ["o"], True, [_bind("o", "array")], "b.tsx", 7)]), +] + + +# ---- DI fact cases ----------------------------------------------------------- + +def _service_from_json(s: dict) -> Service: + return Service( + name=s["name"], + lifetime=s["lifetime"], + deps=tuple(s.get("deps", [])), + disposable=s.get("disposable", False), + file=s.get("file", "?"), + line=s.get("line", 0), + weak_deps=tuple(s.get("weak_deps", [])), + root_resolves=tuple(s.get("root_resolves", [])), + root_resolve_sites=tuple(tuple(t) for t in s.get("root_resolve_sites", [])), + scope_cached=tuple(s.get("scope_cached", [])), + scope_cache_sites=tuple(tuple(t) for t in s.get("scope_cache_sites", [])), + ) + + +def _svc(name: str, lifetime: str, file: str, line: int, **kw) -> dict: + d = {"name": name, "lifetime": lifetime, "file": file, "line": line} + d.update(kw) + return d + + +_DI_CASES: list[tuple[str, list[dict]]] = [ + ("di001_direct", + [_svc("App", "singleton", "reg.cs", 5, deps=["Db"]), + _svc("Db", "scoped", "reg.cs", 6)]), + ("di001_transitive", + [_svc("App", "singleton", "reg.cs", 5, deps=["Mid"]), + _svc("Mid", "transient", "reg.cs", 6, deps=["Db"]), + _svc("Db", "scoped", "reg.cs", 7)]), + ("di001_inner_singleton_not_double_reported", + [_svc("A", "singleton", "reg.cs", 5, deps=["B"]), + _svc("B", "singleton", "reg.cs", 6, deps=["Db"]), + _svc("Db", "scoped", "reg.cs", 7)]), + ("di001_duplicate_scoped_reported_once", + [_svc("App", "singleton", "reg.cs", 5, deps=["M1", "M2"]), + _svc("M1", "transient", "reg.cs", 6, deps=["Db"]), + _svc("M2", "transient", "reg.cs", 7, deps=["Db"]), + _svc("Db", "scoped", "reg.cs", 8)]), + ("di_cycle_guard", + [_svc("App", "singleton", "reg.cs", 5, deps=["T"]), + _svc("T", "transient", "reg.cs", 6, deps=["T", "Db"]), + _svc("Db", "scoped", "reg.cs", 7)]), + ("di_unknown_lifetime_ignored", + [_svc("App", "singleton", "reg.cs", 5, deps=["Mystery", "Db"]), + _svc("Mystery", "prototype", "reg.cs", 6), # unknown lifetime -> ignored + _svc("Db", "scoped", "reg.cs", 7)]), + ("di002_weak", + [_svc("App", "singleton", "reg.cs", 5, weak_deps=["Db"]), + _svc("Db", "scoped", "reg.cs", 6)]), + ("di003_transient_disposable", + [_svc("App", "singleton", "reg.cs", 5, deps=["Conn"]), + _svc("Conn", "transient", "reg.cs", 6, disposable=True)]), + ("di004_direct_call_site_anchor", + [_svc("App", "singleton", "reg.cs", 5, root_resolves=["Conn"], + root_resolve_sites=[["Conn", "call.cs", 42]]), + _svc("Conn", "transient", "reg.cs", 6, disposable=True)]), + ("di004_transitive_disposable_entry_call_site", + [_svc("App", "singleton", "reg.cs", 5, root_resolves=["Mid"], + root_resolve_sites=[["Mid", "call.cs", 42]]), + _svc("Mid", "transient", "reg.cs", 6, deps=["Conn"]), + _svc("Conn", "transient", "reg.cs", 7, disposable=True)]), + ("di005_direct_cache_site_anchor", + [_svc("App", "singleton", "reg.cs", 5, scope_cached=["Db"], + scope_cache_sites=[["Db", "store.cs", 50]]), + _svc("Db", "scoped", "reg.cs", 6)]), + ("di005_transitive_scoped_entry_cache_site", + [_svc("App", "singleton", "reg.cs", 5, scope_cached=["Mid"], + scope_cache_sites=[["Mid", "store.cs", 50]]), + _svc("Mid", "transient", "reg.cs", 6, deps=["Db"]), + _svc("Db", "scoped", "reg.cs", 7)]), + ("di_multi_file_ordering", + [_svc("A2", "singleton", "z.cs", 3, deps=["Db2"]), + _svc("Db2", "scoped", "z.cs", 4), + _svc("A1", "singleton", "a.cs", 9, deps=["Db1"]), + _svc("Db1", "scoped", "a.cs", 10)]), + # duplicate site records for the SAME entry type are LAST-WINS (Python builds + # the site lookup with a dict comprehension); the verdict anchors at second.*. + ("di004_duplicate_entry_sites_last_wins", + [_svc("App", "singleton", "reg.cs", 5, root_resolves=["Conn"], + root_resolve_sites=[["Conn", "first.cs", 10], ["Conn", "second.cs", 20]]), + _svc("Conn", "transient", "reg.cs", 6, disposable=True)]), + ("di005_duplicate_entry_sites_last_wins", + [_svc("App", "singleton", "reg.cs", 5, scope_cached=["Db"], + scope_cache_sites=[["Db", "first.cs", 30], ["Db", "second.cs", 40]]), + _svc("Db", "scoped", "reg.cs", 6)]), +] + + +# ---- bridge primary-anchor selection (ownir._di004_primary/_di005_primary) --- + +def _di001_2_3_primary(c) -> tuple[str, int]: + return (c.file, c.line) + + +def _di004_primary(c) -> tuple[str, int]: + if getattr(c, "resolved_line", 0) >= 1: + return (c.resolved_file, c.resolved_line) + return (c.file, c.line) + + +def _di005_primary(c) -> tuple[str, int]: + if getattr(c, "cached_line", 0) >= 1: + return (c.cached_file, c.cached_line) + return (c.file, c.line) + + +def _di_expected(services: list[Service]) -> list[list[object]]: + """Every DI verdict as [file, line, code], sorted by (file, line, code) — the + bridge's combined DI finding order after its final sort.""" + rows: list[tuple[str, int, str]] = [] + for c in find_captive_dependencies(services): + f, ln = _di001_2_3_primary(c) + rows.append((f, ln, "DI001")) + for c in find_captured_transient_disposables(services): + f, ln = _di001_2_3_primary(c) + rows.append((f, ln, "DI003")) + for c in find_weak_captive_dependencies(services): + f, ln = _di001_2_3_primary(c) + rows.append((f, ln, "DI002")) + for c in find_explicit_root_resolutions(services): + f, ln = _di004_primary(c) + rows.append((f, ln, "DI004")) + for c in find_scope_cached_captives(services): + f, ln = _di005_primary(c) + rows.append((f, ln, "DI005")) + rows.sort(key=lambda r: (r[0], r[1], r[2])) + return [[f, ln, code] for (f, ln, code) in rows] + + +def _effect_expected(effects: list[Effect]) -> list[list[object]]: + """Every EFF001 verdict as [file, line, "EFF001"] in finder order + (sorted by (file, line, dep)).""" + return [[s.file, s.line, "EFF001"] for s in find_effect_storms(effects)] + + +def build() -> dict[str, object]: + effect_cases = [] + for name, facts in _EFFECT_CASES: + effects = [_effect_from_json(e) for e in facts] + effect_cases.append( + {"name": name, "effects": facts, "expected": _effect_expected(effects)}) + di_cases = [] + for name, facts in _DI_CASES: + services = [_service_from_json(s) for s in facts] + di_cases.append( + {"name": name, "services": facts, "expected": _di_expected(services)}) + return { + "comment": ( + "GENERATED by tests/test_di_eff_fact_parity.py --write; do not edit. " + "Python (ownlang.effects / ownlang.di) is authoritative; " + "rust/crates/own-analysis/tests/fact_parity.rs replays the same facts " + "and must reproduce the (file, line, code) verdicts exactly (#214)." + ), + "effect_cases": effect_cases, + "di_cases": di_cases, + } + + +def _render(data: dict[str, object]) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + +def run() -> int: + expected = _render(build()) + if not os.path.exists(FIXTURE): + print(f"FAIL: {FIXTURE} missing; regenerate with " + f"'python tests/test_di_eff_fact_parity.py --write'") + return 1 + with open(FIXTURE, encoding="utf-8") as f: + actual = f.read() + if actual != expected: + print(f"FAIL: {FIXTURE} is stale (a finder or the anchor rule changed); " + f"regenerate with 'python tests/test_di_eff_fact_parity.py --write' " + f"and re-run the Rust side (cd rust && cargo test)") + return 1 + data = json.loads(actual) + n_eff = sum(len(c["expected"]) for c in data["effect_cases"]) + n_di = sum(len(c["expected"]) for c in data["di_cases"]) + print(f"DI/effect fact parity OK: {len(data['effect_cases'])} effect cases " + f"({n_eff} verdicts), {len(data['di_cases'])} DI cases ({n_di} verdicts) " + f"verified in sync") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + os.makedirs(os.path.dirname(FIXTURE), exist_ok=True) + with open(FIXTURE, "w", encoding="utf-8") as f: + f.write(_render(build())) + print(f"wrote {FIXTURE}") + raise SystemExit(0) + raise SystemExit(run()) diff --git a/tests/test_diag_fixtures.py b/tests/test_diag_fixtures.py new file mode 100644 index 00000000..f2928cfc --- /dev/null +++ b/tests/test_diag_fixtures.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Shared diagnostics parity fixtures (P-022 migration step 4, issue #214) — Python side. + +The Rust `own-analysis` crate (once ported) parses `.own` source with +`own-syntax`, lowers it with `own-cfg`, runs the worklist solver + the ownership +/ lifetime analyses, and constructs `own-diagnostics` verdicts. This fixture is +the **frozen `(path, line, code)` contract** the differential oracle diffs: for +every `.own` input the Rust `check` surface must emit the same ordered list of +`[line, code]` pairs Python's `check` surface does — including intra-location +(same-line, same-code) ordering, which the list position pins. + +Copy-pasted expectations rot, so both sides assert the same corpus: +`tests/fixtures/diag_parity.json`. This mirrors the #203 CFG-parity ratchet +(`tests/test_cfg_fixtures.py`), one layer up: CFG-parity froze `(line, code)` +*resolver* diagnostics at the lowering seam; this freezes the full **verdict** +`(line, code)` set at the `check` seam. + +Scope of this seam (checkpoint 1, before the semantic port): + +* The compared surface is the `check` command's diagnostics — exactly + `ownlang.__main__._collect`: parse (`own-syntax`), and on a lex/parse error + emit a single synthetic **OWN020** at the error line (a preserved Python + quirk — a *syntax* error is reported under the "unsupported construct" code; + see the checkpoint-1 note); otherwise run `check_module` (buffer-policy + validation + `check_lifetimes` + per-function `analyze`), whose result is + already sorted by `(line, code)` with a stable tie order. +* This exercises **ownership**, **lifetime/region**, and **buffer-policy** + diagnostics — every family `check_module` produces. The **effects (EFF*)** + and **DI (DI*)** families are *sidecar analyses the OwnIR bridge routes facts + to* (`ownlang/ownir.py::check_facts`), not the `.own` core lattice, so no + `.own` input exercises them; their parity needs OwnIR **fact** fixtures and + lands with `own-bridge` (migration step 6), not here. Message text, evidence + slices and SARIF are later steps (5) and deliberately NOT frozen here. + +Compared on **code + line only**, per input, in emission order. + +Run: python tests/test_diag_fixtures.py (verify) + python tests/test_diag_fixtures.py --write (regenerate) + python tests/run_tests.py (runs it as part of 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.__main__ import _collect + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "diag_parity.json") + +# Directories swept for `.own` inputs — the same corpus #214 names and the CFG +# ratchet already sweeps (`tests/test_cfg_fixtures.py`), so the two seams stay +# on identical inputs. +CORPUS_DIRS = ["corpus", "examples", os.path.join("tests", "fixtures")] + +# Curated cases exercising verdict shapes as concentrated single files (a leak, +# use-after-release, double-release, use-after-move, an escape/lifetime promotion, +# a buffer-policy violation, and the OWN020 parse-error quirk). Outcomes are +# COMPUTED by running the real `check` surface, never hand-written — a curated +# case pins a shape, it does not assert a guessed verdict. +_PRELUDE = ( + "module M\n" + "resource Conn { acquire open release close }\n" + 'resource Token { acquire mint release burn kind "subscription token" }\n' + "extern fn Fill(borrow_mut Conn);\n" + "extern fn Hash(borrow Conn);\n" + "extern fn Store(consume Conn);\n" +) + +_CURATED: list[tuple[str, str]] = [ + ( + "curated_leak_and_release", + _PRELUDE + + "fn leaks() {\n" + " let c = acquire Conn(1);\n" + " return;\n" + "}\n" + "fn clean() {\n" + " let c = acquire Conn(1);\n" + " release c;\n" + " return;\n" + "}\n", + ), + ( + "curated_use_after_release", + _PRELUDE + + "fn f() {\n" + " let c = acquire Conn(1);\n" + " release c;\n" + " Hash(c);\n" + " return;\n" + "}\n", + ), + ( + "curated_double_release", + _PRELUDE + + "fn f() {\n" + " let c = acquire Conn(1);\n" + " release c;\n" + " release c;\n" + " return;\n" + "}\n", + ), + ( + "curated_use_after_move", + _PRELUDE + + "fn f() {\n" + " let c = acquire Conn(1);\n" + " let d = move c;\n" + " Hash(c);\n" + " Store(d);\n" + " return;\n" + "}\n", + ), + ( + "curated_maybe_release_branch", + _PRELUDE + + "fn f(n: int) {\n" + " let c = acquire Conn(1);\n" + " if (n) { release c; }\n" + " Hash(c);\n" + " return;\n" + "}\n", + ), + ( + "curated_buffer_policy", + "module M\n" + "fn f(n: int) {\n" + " let a = Buffer.inline(999999);\n" + " release a;\n" + "}\n", + ), + ( + "curated_parse_error_is_own020", + "module M\nfn f( {\n", + ), +] + + +def _corpus_files() -> list[str]: + """Every `.own` file under the swept directories, as repo-relative POSIX + paths, sorted — a stable, platform-independent case ordering.""" + found: list[str] = [] + for base in CORPUS_DIRS: + root = os.path.join(REPO_ROOT, base) + for dirpath, _dirs, files in os.walk(root): + for name in files: + if name.endswith(".own"): + rel = os.path.relpath(os.path.join(dirpath, name), REPO_ROOT) + found.append(rel.replace(os.sep, "/")) + return sorted(found) + + +def _diags_for(source: str) -> list[list[object]]: + """The `check` surface's ordered `[line, code]` verdict list for one source. + + `_collect` is the exact function `python -m ownlang check` runs: it wraps a + lex/parse failure as a single synthetic OWN020 and otherwise returns + `check_module`'s diagnostics, already sorted by `(line, code)` with a stable + intra-tie order. We freeze that order verbatim — list position is the + deterministic intra-location ordering the oracle pins.""" + diags, _mod = _collect(source) + return [[d.line, d.code] for d in diags] + + +def _case(name: str, source: str) -> dict[str, object]: + return {"name": name, "source": source, "diags": _diags_for(source)} + + +def build() -> dict[str, object]: + cases: list[dict[str, object]] = [] + for rel in _corpus_files(): + with open(os.path.join(REPO_ROOT, rel), encoding="utf-8") as f: + cases.append(_case(rel, f.read())) + for name, source in _CURATED: + cases.append(_case(name, source)) + return { + "comment": ( + "GENERATED by tests/test_diag_fixtures.py --write; do not edit. " + "Python (ownlang) is authoritative; rust/crates/own-analysis replays " + "every case through the `check` surface and must match the ordered " + "(line, code) verdict list exactly (issue #214, P-022 step 4)." + ), + "cases": cases, + } + + +def _render(data: dict[str, object]) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + +def run() -> int: + expected = _render(build()) + if not os.path.exists(FIXTURE): + print(f"FAIL: {FIXTURE} missing; regenerate with " + f"'python tests/test_diag_fixtures.py --write'") + return 1 + with open(FIXTURE, encoding="utf-8") as f: + actual = f.read() + if actual != expected: + print(f"FAIL: {FIXTURE} is stale (the corpus or the checker changed); " + f"regenerate with 'python tests/test_diag_fixtures.py --write' and " + f"re-run the Rust side (cd rust && cargo test)") + return 1 + data = json.loads(actual) + cases = data["cases"] + n_findings = sum(len(c["diags"]) for c in cases) + n_flagged = sum(1 for c in cases if c["diags"]) + print(f"diagnostics parity fixtures OK: {len(cases)} cases " + f"({n_flagged} with findings, {n_findings} total (line, code) pairs) " + f"verified in sync") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + os.makedirs(os.path.dirname(FIXTURE), exist_ok=True) + with open(FIXTURE, "w", encoding="utf-8") as f: + f.write(_render(build())) + print(f"wrote {FIXTURE}") + raise SystemExit(0) + raise SystemExit(run()) diff --git a/tests/test_diff_gen_fixtures.py b/tests/test_diff_gen_fixtures.py new file mode 100644 index 00000000..a8d30e22 --- /dev/null +++ b/tests/test_diff_gen_fixtures.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Generated differential mini-program battery (P-022 step 4, issue #214). + +A *deterministic* generator of small OwnLang programs built from the ownership +primitives — acquire / release / use / move / call(borrow|consume) / branch / +loop / return — plus the Python `(line, code)` goldens for each. Persisted to +`tests/fixtures/diag_diff_gen.json`; the Rust side +(`rust/crates/own-analysis/tests/diff_gen.rs`) replays every case through the +ported `check` surface with **zero Python** and, on any divergence, prints the +seed + source so it can become a permanent regression fixture. + +Determinism: each program is built from `random.Random(seed)` for `seed in +range(COUNT)`, so regeneration is byte-stable. Declarations are emitted only at +statement-list top level (never inside a branch/loop body) so every reference is +in scope — the generator exercises *ownership* faults (leak / use-after-release / +double-release / use-after-move / borrow-vs-consume), not resolver name errors. + +Run: python tests/test_diff_gen_fixtures.py (verify) + python tests/test_diff_gen_fixtures.py --write (regenerate) + python tests/run_tests.py (runs it as part of the suite) +""" + +from __future__ import annotations + +import json +import os +import random +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.__main__ import _collect + +FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "diag_diff_gen.json") + +# Number of seeded programs. Bounded so the fixture stays reviewable; each seed is +# an independent deterministic program. +COUNT = 160 + +_PRELUDE = ( + "module M\n" + "resource R { acquire open release close }\n" + "extern fn Use(borrow R);\n" + "extern fn Take(consume R);\n" +) + + +def _gen_source(rng: random.Random) -> str: + counter = [0] + live: list[str] = [] # names declared so far (owned handles), always in scope + + def new_var() -> str: + counter[0] += 1 + return f"v{counter[0]}" + + def block(depth: int, n_stmts: int) -> list[str]: + out: list[str] = [] + for _ in range(n_stmts): + out.extend(stmt(depth)) + return out + + def stmt(depth: int) -> list[str]: + # Declarations only at top level so references never go out of scope. + top = depth == 0 + ops = ["use", "release", "move", "call", "branch", "loop"] + if top: + ops = ["acquire", "acquire", *ops] # bias toward introducing resources + op = rng.choice(ops) + + if op == "acquire" and top: + v = new_var() + live.append(v) + return [f"let {v} = acquire R(1);"] + if op in ("use", "release", "move", "call") and not live: + return [] + if op == "use": + return [f"use {rng.choice(live)};"] + if op == "release": + return [f"release {rng.choice(live)};"] + if op == "move" and top: + src = rng.choice(live) + v = new_var() + live.append(v) + return [f"let {v} = move {src};"] + if op == "call": + v = rng.choice(live) + fn = rng.choice(["Use", "Take"]) + return [f"{fn}({v});"] + if op == "branch" and depth < 2: + inner = block(depth + 1, rng.randint(1, 3)) + return ["if (n) {", *[f" {ln}" for ln in inner], "}"] + if op == "loop" and depth < 2: + inner = block(depth + 1, rng.randint(1, 3)) + return ["while (n) {", *[f" {ln}" for ln in inner], "}"] + return [] + + body = block(0, rng.randint(3, 9)) + body.append("return;") + indented = "\n".join(f" {ln}" for ln in body) + return f"{_PRELUDE}fn gen(n: int) {{\n{indented}\n}}\n" + + +def build() -> dict[str, object]: + cases: list[dict[str, object]] = [] + for seed in range(COUNT): + rng = random.Random(seed) + source = _gen_source(rng) + diags, _ = _collect(source) + cases.append( + {"seed": seed, "source": source, "diags": [[d.line, d.code] for d in diags]} + ) + return { + "comment": ( + "GENERATED by tests/test_diff_gen_fixtures.py --write; do not edit. " + "Deterministic seeded ownership mini-programs; Python (ownlang) is " + "authoritative, rust/crates/own-analysis/tests/diff_gen.rs replays " + "each and prints seed+source on divergence (issue #214)." + ), + "cases": cases, + } + + +def _render(data: dict[str, object]) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + +def run() -> int: + expected = _render(build()) + if not os.path.exists(FIXTURE): + print(f"FAIL: {FIXTURE} missing; regenerate with " + f"'python tests/test_diff_gen_fixtures.py --write'") + return 1 + with open(FIXTURE, encoding="utf-8") as f: + actual = f.read() + if actual != expected: + print(f"FAIL: {FIXTURE} is stale (generator or checker changed); " + f"regenerate with 'python tests/test_diff_gen_fixtures.py --write' " + f"and re-run the Rust side (cd rust && cargo test)") + return 1 + data = json.loads(actual) + cases = data["cases"] + n_find = sum(len(c["diags"]) for c in cases) + print(f"generated differential fixtures OK: {len(cases)} seeded programs, " + f"{n_find} total (line, code) pairs, verified in sync") + return 0 + + +if __name__ == "__main__": + if "--write" in sys.argv[1:]: + os.makedirs(os.path.dirname(FIXTURE), exist_ok=True) + with open(FIXTURE, "w", encoding="utf-8") as f: + f.write(_render(build())) + print(f"wrote {FIXTURE}") + raise SystemExit(0) + raise SystemExit(run())