|
| 1 | +# P-016 — Deep C# fact extraction: CFG + flow lowering |
| 2 | + |
| 3 | +- **Status:** draft (P1 — the "make the core actually bite real C#" track) |
| 4 | +- **Depends on:** |
| 5 | + - [P-014](P-014-semantic-resolution.md) Tier A — the `SemanticModel` (**DONE**). |
| 6 | + The hard prerequisite: typed ownership facts are impossible without binding. |
| 7 | + - [P-001](P-001-csharp-extractor.md) — the extractor → OwnIR → core seam, and the |
| 8 | + `ownir_version` contract. |
| 9 | + - [P-005](P-005-idisposable-ownership.md) — the first deep profile this unlocks |
| 10 | + (IDisposable acquire/release on all paths); [P-004](P-004-wpf-lifetime-profile.md) |
| 11 | + (lifetime), [P-007](P-007-arraypool-span.md) (borrow/Span). |
| 12 | + - `spec/OwnCore.md`, `spec/Lifetimes.md` — the fact vocabulary the core *already* |
| 13 | + checks (acquire / move / borrow / use / release / escape / control-flow). |
| 14 | +- **Strategy hub:** [`docs/ROADMAP.md`](../ROADMAP.md). |
| 15 | + |
| 16 | +## Motivation |
| 17 | + |
| 18 | +The core (`ownlang`) is a sound, tested, flow-sensitive ownership / borrow / |
| 19 | +lifetime checker — **but only on the `.own` DSL.** On real C#, the Roslyn frontend |
| 20 | +feeds it a *flat list of pattern-matched resource facts* (`event +=`, IDisposable |
| 21 | +field, pool, local-disposable). None of the core's deep machinery — |
| 22 | +release-on-all-paths across branches, use-after-release, double-release, move, |
| 23 | +borrow aliasing, lifetime ordering — is exercised on real code. The engine runs; |
| 24 | +the fuel line to real C# is a trickle. Two concrete gaps: |
| 25 | + |
| 26 | +1. **The frontend emits no control flow and no per-statement operations.** It does |
| 27 | + not lower real method bodies into acquire / use / release / move / borrow over a |
| 28 | + CFG, so OWN001/002/003/005/… never fire on real C# beyond the shallow patterns. |
| 29 | +2. **The core does not model loops.** `cfg.py`: *"There are no loops, so the CFG is |
| 30 | + a DAG and a single topological pass suffices — this is exactly where loop support |
| 31 | + (worklist + fixpoint) would later plug in."* `OWN020` marks loops/async |
| 32 | + unsupported. Real C# is full of loops. |
| 33 | + |
| 34 | +P-014 Tier A removed the hard prerequisite (the frontend now has a `SemanticModel`). |
| 35 | +This proposal is the plan to feed the *existing* core real facts — a CFG plus |
| 36 | +acquire/use/release/move/borrow — from real C#, **one fact-type per increment**, |
| 37 | +plus the core loop support that lets those facts be checked on real (loopy) methods. |
| 38 | + |
| 39 | +This is the honest answer to "does Own.NET work, or only on paper?": prove one real |
| 40 | +flow-sensitive verdict on real C# end-to-end. If the thin vertical slice (B0+B2 |
| 41 | +below) lights up a true OWN001 on GTM, the concept is proven; if it proves |
| 42 | +intractable, far better to learn that now than after more breadth. |
| 43 | + |
| 44 | +## Dependencies — what we depend on to start |
| 45 | + |
| 46 | +| # | Dependency | Status | |
| 47 | +|---|------------|--------| |
| 48 | +| 1 | Types in the frontend (no typed ownership facts without binding) | ✅ **DONE — P-014 Tier A `SemanticModel`** | |
| 49 | +| 2 | A control-flow graph from real C# | Roslyn `ControlFlowGraph` / `IOperation` exists — needs lowering | |
| 50 | +| 3 | OwnIR (and the bridge) able to carry per-method operations + a CFG | schema growth — see **B0** | |
| 51 | +| 4 | The core handles loops | ❌ missing (`OWN020`); worklist + fixpoint — the set-of-states lattice is finite & union-merged, so it converges — see **A1** | |
| 52 | +| 5 | An honest-skip path for shapes we cannot model (async, exotic flow) | ✅ already the project's philosophy | |
| 53 | + |
| 54 | +Replace the "single topological pass" with a worklist, and the flat fact list with |
| 55 | +a CFG-carrying bridge, and the existing core checks real code. |
| 56 | + |
| 57 | +## Scope — the increments (two tracks that meet at the rich-fact bridge) |
| 58 | + |
| 59 | +### Track A — core only (pure DSL, no frontend, `.own`-tested) |
| 60 | + |
| 61 | +- **A1 — Loops.** Replace the single topological pass over the DAG (`cfg.py`, |
| 62 | + `analysis.py`) with a worklist + fixpoint over back-edges. The lattice is the |
| 63 | + finite set-of-states (OwnCore §3, union at merges) → monotone → it converges |
| 64 | + (confirm whether widening is even needed). Removes the `OWN020` "loops" clause. |
| 65 | + Fully independent of the frontend; pinned by new `.own` loop cases + the gallery. |
| 66 | + |
| 67 | +### Track B — frontend depth (needs the `SemanticModel`, now present) |
| 68 | + |
| 69 | +- **B0 — Direct-Module bridge (and kill the double parse).** Two steps: |
| 70 | + - **B0a (refactor, no behavior change):** today the bridge lowers OwnIR facts to |
| 71 | + `.own` **source text** (`ownir.py` `to_own`) and the core **re-parses that text** |
| 72 | + (`__main__._collect` → `parse`) before checking — a parse of a tree we just |
| 73 | + built, a round-trip that has existed since P-001 and doubles the lowering work |
| 74 | + (every new fact must be expressible as, *and survive a round-trip through*, |
| 75 | + generated `.own` text). Replace it: build the core `Module` AST |
| 76 | + (`ast_nodes`) **directly** from facts and call `check_module` — no text, no |
| 77 | + re-parse. The seam is already split for this (`ownir.py`: *"builds a Module and |
| 78 | + calls `__main__.check_module` directly … the seam is already split so that |
| 79 | + switch is additive, not a rewrite"*). Pin against the existing fixtures: same |
| 80 | + findings, one fewer parse. |
| 81 | + - **B0b (enabler):** extend OwnIR — and the direct `Module` construction — to carry |
| 82 | + per-method **basic blocks + acquire / use / release / move / borrow / return** |
| 83 | + operations: the schema the deep checks need. Bumps `OWNIR_VERSION` (a new fact |
| 84 | + *category*, NOT additive like P-014's OWN050; the version gate already forces the |
| 85 | + extractor and core to move together). |
| 86 | +- **B1 / B2 — IDisposable flow for locals.** Lower a method's `new` / `using` / |
| 87 | + `.Dispose()` / `return` into acquire / release / escape over a Roslyn CFG → real |
| 88 | + **OWN001** (leaked on an exception/early-return path), **OWN002** (use after |
| 89 | + dispose), **OWN003** (double dispose) on live C#. The IDisposable story (P-005) — |
| 90 | + the most common .NET resource bug, and the first time the core's flow analysis |
| 91 | + bites real code. B2 (`using`/try-finally → scoped release) is the smallest first |
| 92 | + slice; do it before the general case. |
| 93 | +- **B3 — Move / ownership transfer.** `return disposable`, or passing it to a callee |
| 94 | + that consumes it → move / escape (P-005 D5). Intraprocedural, driven by declared |
| 95 | + signatures, not whole-program tracing. |
| 96 | +- **B4 — Borrow / Span.** `Rent` → view → `Return`, `Span`/`ref` aliasing (P-007) — |
| 97 | + the borrow checker's crown jewel, hardest on C# (ref structs, Span lifetimes). |
| 98 | +- **B5 — Lifetime ordering.** WPF005 escape (`OWN014`) and DI captive (P-006, |
| 99 | + partly built) — the hardest; needs an explicit lifetime model. |
| 100 | + |
| 101 | +**Dependency order:** `B0 → B1 → B3 → B4 / B5`. `A1` is independent but needed for |
| 102 | +B1 to cover loopy methods — until A1 lands, a method containing a loop is honestly |
| 103 | +skipped (`OWN020`), not guessed. B0+B2 and A1 can proceed in parallel. |
| 104 | + |
| 105 | +## Non-goals |
| 106 | + |
| 107 | +- A full C# semantic front-end. We lower the operations the core already models, not |
| 108 | + the language. `async`/`await` stays an honest skip (`OWN020`) until a real bug |
| 109 | + demands it; whole-program/interprocedural analysis beyond signature-declared |
| 110 | + transfer; the XAML/binding engine. The "refuse the soul-eating version" rule holds. |
| 111 | +- Rewriting the core. A1 *extends* the existing flow engine (worklist) — it does not |
| 112 | + replace the lattice. B0–B5 only feed the core; they decide no verdicts. |
| 113 | + |
| 114 | +## Sketch |
| 115 | + |
| 116 | +```text |
| 117 | + A1: worklist+fixpoint (loops) ─────────────┐ |
| 118 | + v |
| 119 | +real *.cs ─[Roslyn CFG + IOperation]─[B1..B5: acquire/use/release/move/borrow] |
| 120 | + ─[B0b: per-method ops+blocks OwnIR]─[B0a: build Module directly] |
| 121 | + ─[the one core]──> OWN001/002/003/005/014 @ the C# line |
| 122 | +``` |
| 123 | + |
| 124 | +## Relationship to the spec & docs (anti-drift) |
| 125 | + |
| 126 | +- **`spec/` core semantics: unchanged by Track B.** The core already models |
| 127 | + acquire/borrow/move/lifetime for `.own`; B0–B5 *feed* it those facts from C#, they |
| 128 | + do not change what it means. **A1 does change the core** (it analyses loops): when |
| 129 | + it lands, `spec/OwnCore.md`'s "loops out of scope" (§10) and the `OWN020` |
| 130 | + loops clause are updated to describe the worklist — spec follows code. |
| 131 | +- **OwnIR contract:** B0b **bumps `OWNIR_VERSION`** — rich per-method facts are a new |
| 132 | + category, not an additive optional field (contrast P-014's OWN050, which did not |
| 133 | + bump). The load-time version gate already makes a mismatched extractor/core fail |
| 134 | + loudly, so the bump is safe by construction. |
| 135 | +- **No new diagnostic codes** — B1–B5 reuse the existing OWN001/002/003/005/008/014. |
| 136 | + The only catalogue change is *removing* the loops clause from OWN020 when A1 ships. |
| 137 | +- **"One checker" preserved:** B0a removes a parse, not a decider; the core remains |
| 138 | + the single source of truth. |
| 139 | + |
| 140 | +## Open questions |
| 141 | + |
| 142 | +1. **B0b schema shape.** Grow the OwnIR JSON with a per-function ops+blocks array |
| 143 | + the bridge maps to a `Module`, or have the extractor emit a serialized `Module` |
| 144 | + directly? (Lean: ops+blocks JSON — keeps the extractor decider-free.) |
| 145 | +2. **try-finally / `using` modeling.** Map to a "release on all paths" region, or to |
| 146 | + explicit release on each CFG exit edge? |
| 147 | +3. **How much of Roslyn's `ControlFlowGraph` to consume** (its basic blocks + the |
| 148 | + operations we map) vs build our own CFG from syntax. (Lean: consume Roslyn's — it |
| 149 | + already normalizes `using`/`try`/short-circuits.) |
| 150 | +4. **Loop fixpoint:** does the finite set-of-states lattice converge fast enough |
| 151 | + as-is, or is widening warranted on pathological back-edges? (Likely converges.) |
| 152 | +5. **Honest-skip granularity:** on an unsupported construct, skip the whole method, |
| 153 | + or analyse the modelable prefix and skip the rest? (Conservative: skip the method, |
| 154 | + emit `OWN020` once.) |
| 155 | +6. **Which slice proves the concept fastest** — B0a+B2 on loop-free methods is the |
| 156 | + proposed existential spike; confirm it surfaces a real OWN001 on GTM before |
| 157 | + investing in B3–B5. |
0 commit comments