Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions docs/notes/p022-diag-parity-checkpoint1.md
Original file line number Diff line number Diff line change
@@ -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<T>` 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<T>` 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<DiagKey>)`, 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.
111 changes: 111 additions & 0 deletions docs/notes/p022-full-parity-checkpoint3.md
Original file line number Diff line number Diff line change
@@ -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**.
Loading
Loading