diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4ce0f7a..e7fc72ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,9 @@ jobs: frontend/roslyn/samples/MessengerViewModel.cs \ frontend/roslyn/samples/PooledBufferSample.cs \ frontend/roslyn/samples/LocalDisposableSample.cs \ + frontend/roslyn/samples/SelfOwnedViewModel.cs \ + frontend/roslyn/samples/StaticHandlerViewModel.cs \ + frontend/roslyn/samples/SampleTypes.cs \ -o "$RUNNER_TEMP/facts.json" cat "$RUNNER_TEMP/facts.json" - name: Check facts through the core @@ -173,6 +176,16 @@ jobs: if echo "$out" | grep -qE "'guarded'|'moved'"; then echo "FAIL: using/returned local wrongly reported"; exit 1 fi + # P-004 self-owned exemption: a subscription whose source is a field the + # class constructs (owns) is a GC-collectable cycle, not a leak — silent. + if echo "$out" | grep -q "SelfOwnedViewModel.cs"; then + echo "FAIL: a self-owned subscription was wrongly reported"; exit 1 + fi + # P-004 static-handler exemption: a static-method handler has a null + # delegate target — no instance retained, so not a leak — silent. + if echo "$out" | grep -q "StaticHandlerViewModel.cs"; then + echo "FAIL: a static-handler subscription was wrongly reported"; exit 1 + fi echo "OK: real C# -> facts -> OWN001 (subscription + timer + field + Subscribe + pool + local) at the C# location" # The distribution surface (Уровень 1): the own-check.sh orchestrator walks a diff --git a/README.md b/README.md index 9b85ad08..cfeb134b 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,10 @@ fn CustomerViewModel(bus: EventBus lifetime App) lifetime ViewModel { ``` **P-001 — настоящий C# (а не hand-reduced).** Узкий Roslyn-экстрактор -(`frontend/roslyn/`, syntax-only) находит `event += без -=` в реальном `.cs` и -эмитит OwnIR-факты; Python-мост (`python -m ownlang ownir facts.json`) прогоняет -их через **то же ядро** и выдаёт OWN001 **на месте C#**: +(`frontend/roslyn/`, type-aware: project-local `SemanticModel`, см. P-014) находит +`event += без -=` в реальном `.cs` (по семантике: `sum += value` — не событие, а +арифметика) и эмитит OwnIR-факты; Python-мост (`python -m ownlang ownir facts.json`) +прогоняет их через **то же ядро** и выдаёт OWN001 **на месте C#**: ```text CustomerViewModel.cs:9: error: [OWN001] event 'bus.CustomerChanged' is subscribed diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 23b8365a..e8f5be2d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -35,9 +35,12 @@ long-term identity the backlog is aiming at: - Concretely: prove Own.NET finds **one** real memory/resource bug in real C#, then widen the frontend to fit exactly the next real bug. - **Narrow C# frontend, intraprocedural first.** The frontend's job is not to - "understand C#" (SemanticModel hides async, generics, LINQ, closures, pattern - matching, overload resolution, nullable flow, source generators…). Its job is - to extract *facts*: acquire / borrow / use / release / escape / control-flow. + "understand C#" — it uses a project-local `SemanticModel` only for binding and + type resolution (is a `+=` LHS an event or a number? — [P-014](proposals/P-014-semantic-resolution.md)), + not for whole-language understanding (async, generics, LINQ, closures, pattern + matching, overload resolution, nullable flow, source generators stay out of + scope). Its job is to extract *facts*: acquire / borrow / use / release / escape + / control-flow. - **Refuse the soul-eating version.** Every proposal's Non-goals section is the most important one. Boredom keeps projects alive. diff --git a/docs/howto-visual-studio.md b/docs/howto-visual-studio.md index 75d23807..823de48b 100644 --- a/docs/howto-visual-studio.md +++ b/docs/howto-visual-studio.md @@ -150,9 +150,10 @@ instead of the bash command. - **Heuristic findings can be false positives** (e.g. ownership handed to a callee). Treat output as a reviewer, not a gate, until you've calibrated it on your codebase — and prefer `--fail-on-finding` only once it's quiet. -- **One method at a time, syntax-only.** The extractor does not do - interprocedural/`async`/whole-program analysis yet (by design — see the - ROADMAP). It honestly skips what it can't model rather than guessing. +- **One method at a time, type-aware (project-local `SemanticModel`).** The + extractor does not do interprocedural/`async`/whole-program analysis yet (by + design — see the ROADMAP). It honestly skips what it can't model rather than + guessing. - **`bin`/`obj`/generated files are skipped**; paths are reported relative to the scan root so they resolve in the editor and on the PR. diff --git a/docs/proposals/P-001-csharp-extractor.md b/docs/proposals/P-001-csharp-extractor.md index cb7fe855..dc471469 100644 --- a/docs/proposals/P-001-csharp-extractor.md +++ b/docs/proposals/P-001-csharp-extractor.md @@ -9,7 +9,8 @@ The `event += without -=` pattern, end-to-end, exactly along the recommended seam: -- **Roslyn extractor** (`frontend/roslyn/OwnSharp.Extractor`, C#, syntax-only): +- **Roslyn extractor** (`frontend/roslyn/OwnSharp.Extractor`, C#, type-aware: + project-local `SemanticModel` — see [P-014](P-014-semantic-resolution.md)): scans `.cs`, emits OwnIR facts (JSON) — built & run in CI (`wpf-extractor`). - **Python fact bridge** (`ownlang/ownir.py`, `python -m ownlang ownir`): lowers facts to a synthetic `.own` sketch, runs the **existing core**, and maps the @@ -31,7 +32,9 @@ narrow class of leaks the core already models: event subscriptions, timers, `IDisposable` fields, ignored `Subscribe` results. This is **not** a full C# ownership front-end (generics, async, dataflow — that is -human-years and explicitly rejected). It is a syntactic/local pattern extractor. +human-years and explicitly rejected). It is a narrow, intraprocedural fact +extractor — type-aware for the one fact that needs it (the type of a `+=` LHS), +via a project-local `SemanticModel` ([P-014](P-014-semantic-resolution.md)). ## Scope (v0) diff --git a/docs/proposals/P-004-wpf-lifetime-profile.md b/docs/proposals/P-004-wpf-lifetime-profile.md index 407024b6..7a2cb1ee 100644 --- a/docs/proposals/P-004-wpf-lifetime-profile.md +++ b/docs/proposals/P-004-wpf-lifetime-profile.md @@ -1,8 +1,8 @@ # P-004 — WPF / UI lifetime leak profile - **Status:** in progress (P0) — WPF001 (v0) + **WPF002 (timer)** + **WPF003 - (IDisposable field)** + **WPF004 (ignored Subscribe) built**; WPF005 (escape) - next + (IDisposable field)** + **WPF004 (ignored Subscribe)** + **self-owned & static- + handler exemptions (P-014 Tier A) built**; WPF005 (escape) next - **Depends on:** [P-001](P-001-csharp-extractor.md) (the extractor + OwnIR seam), `spec/OwnCore.md`, `spec/Lifetimes.md` (OWN001 leak, OWN014 region escape). See [`docs/ROADMAP.md`](../ROADMAP.md) for where this sits (Milestones 1–2). @@ -46,6 +46,23 @@ escapes(this, App) -> a strong capture by a longer-lived source (feeds OWN014) Dispose/OnClosed/Unloaded -> a permitted release region ``` +**Lifetime exemptions (built, P-014 Tier A).** Two sound, syntax-cheap cases where +a `+=` without `-=` is provably *not* a leak and is dropped — decided semantically +(symbols, not text): +- **Self-owned source** — the event source is `this`, or a field/local the class + constructs (and so owns); the `source <-> this` cycle outlives nothing and is + GC-collectable. +- **Static handler** — `+= StaticMethod` stores a delegate whose `Target` is null, + so no instance is retained, however long-lived the source. + +Timers are excluded from both (a *running* timer is dispatcher-rooted regardless). +Samples: `SelfOwnedViewModel.cs` / `StaticHandlerViewModel.cs` (silent) vs +`CustomerViewModel.cs` (injected instance source → leak). On GTM these keep +`VCreate`/`VRibbon`'s *instance* subscriptions to the static +`LicContext.LicenseDataChanged` as leaks, while dropping the static-class +`Context`'s subscription to the *same* event — the deciding factor is the +subscriber/handler, not the source. + The corpus already pins three of these against real core codes: `corpus/wpf/zombie-viewmodel` (OWN001), `viewmodel-escapes-to-app` (OWN014), `handler-use-after-dispose` (OWN002). WPF004/WPF005 are the increments that emit diff --git a/docs/proposals/P-014-semantic-resolution.md b/docs/proposals/P-014-semantic-resolution.md index 3d978b57..82563fa3 100644 --- a/docs/proposals/P-014-semantic-resolution.md +++ b/docs/proposals/P-014-semantic-resolution.md @@ -261,7 +261,10 @@ T1→T2 are sequential (infra then fix); T3/T4 land with T2; T5 (docs) any time; **P-004** — its mechanisms are `[OwnIgnore("source lifetime is shorter")]` (P-004:60-61) and WPF005 firing OWN014 only on a *longer-lived* source (P-004:35,45). P-014 removes the *gross* noise (arithmetic, unresolved - externals) so P-004's heuristic operates on real subscriptions only. + externals) so P-004's heuristic operates on real subscriptions only. (The first + P-004 increment — the self-owned-source exemption, skipping a subscription whose + source is `this` or a field the class constructs — landed alongside this work; + see P-004.) - **Requiring a green build for Tier A.** The compilation degrades gracefully: partial resolution still types the locals/fields Tier A needs. - **A new `info`/`note` severity level.** The unresolved channel is a `warning`. diff --git a/docs/proposals/P-016-deep-fact-extraction.md b/docs/proposals/P-016-deep-fact-extraction.md new file mode 100644 index 00000000..2a3ac20e --- /dev/null +++ b/docs/proposals/P-016-deep-fact-extraction.md @@ -0,0 +1,157 @@ +# P-016 — Deep C# fact extraction: CFG + flow lowering + +- **Status:** draft (P1 — the "make the core actually bite real C#" track) +- **Depends on:** + - [P-014](P-014-semantic-resolution.md) Tier A — the `SemanticModel` (**DONE**). + The hard prerequisite: typed ownership facts are impossible without binding. + - [P-001](P-001-csharp-extractor.md) — the extractor → OwnIR → core seam, and the + `ownir_version` contract. + - [P-005](P-005-idisposable-ownership.md) — the first deep profile this unlocks + (IDisposable acquire/release on all paths); [P-004](P-004-wpf-lifetime-profile.md) + (lifetime), [P-007](P-007-arraypool-span.md) (borrow/Span). + - `spec/OwnCore.md`, `spec/Lifetimes.md` — the fact vocabulary the core *already* + checks (acquire / move / borrow / use / release / escape / control-flow). +- **Strategy hub:** [`docs/ROADMAP.md`](../ROADMAP.md). + +## Motivation + +The core (`ownlang`) is a sound, tested, flow-sensitive ownership / borrow / +lifetime checker — **but only on the `.own` DSL.** On real C#, the Roslyn frontend +feeds it a *flat list of pattern-matched resource facts* (`event +=`, IDisposable +field, pool, local-disposable). None of the core's deep machinery — +release-on-all-paths across branches, use-after-release, double-release, move, +borrow aliasing, lifetime ordering — is exercised on real code. The engine runs; +the fuel line to real C# is a trickle. Two concrete gaps: + +1. **The frontend emits no control flow and no per-statement operations.** It does + not lower real method bodies into acquire / use / release / move / borrow over a + CFG, so OWN001/002/003/005/… never fire on real C# beyond the shallow patterns. +2. **The core does not model loops.** `cfg.py`: *"There are no loops, so the CFG is + a DAG and a single topological pass suffices — this is exactly where loop support + (worklist + fixpoint) would later plug in."* `OWN020` marks loops/async + unsupported. Real C# is full of loops. + +P-014 Tier A removed the hard prerequisite (the frontend now has a `SemanticModel`). +This proposal is the plan to feed the *existing* core real facts — a CFG plus +acquire/use/release/move/borrow — from real C#, **one fact-type per increment**, +plus the core loop support that lets those facts be checked on real (loopy) methods. + +This is the honest answer to "does Own.NET work, or only on paper?": prove one real +flow-sensitive verdict on real C# end-to-end. If the thin vertical slice (B0+B2 +below) lights up a true OWN001 on GTM, the concept is proven; if it proves +intractable, far better to learn that now than after more breadth. + +## Dependencies — what we depend on to start + +| # | Dependency | Status | +|---|------------|--------| +| 1 | Types in the frontend (no typed ownership facts without binding) | ✅ **DONE — P-014 Tier A `SemanticModel`** | +| 2 | A control-flow graph from real C# | Roslyn `ControlFlowGraph` / `IOperation` exists — needs lowering | +| 3 | OwnIR (and the bridge) able to carry per-method operations + a CFG | schema growth — see **B0** | +| 4 | The core handles loops | ❌ missing (`OWN020`); worklist + fixpoint — the set-of-states lattice is finite & union-merged, so it converges — see **A1** | +| 5 | An honest-skip path for shapes we cannot model (async, exotic flow) | ✅ already the project's philosophy | + +Replace the "single topological pass" with a worklist, and the flat fact list with +a CFG-carrying bridge, and the existing core checks real code. + +## Scope — the increments (two tracks that meet at the rich-fact bridge) + +### Track A — core only (pure DSL, no frontend, `.own`-tested) + +- **A1 — Loops.** Replace the single topological pass over the DAG (`cfg.py`, + `analysis.py`) with a worklist + fixpoint over back-edges. The lattice is the + finite set-of-states (OwnCore §3, union at merges) → monotone → it converges + (confirm whether widening is even needed). Removes the `OWN020` "loops" clause. + Fully independent of the frontend; pinned by new `.own` loop cases + the gallery. + +### Track B — frontend depth (needs the `SemanticModel`, now present) + +- **B0 — Direct-Module bridge (and kill the double parse).** Two steps: + - **B0a (refactor, no behavior change):** today the bridge lowers OwnIR facts to + `.own` **source text** (`ownir.py` `to_own`) and the core **re-parses that text** + (`__main__._collect` → `parse`) before checking — a parse of a tree we just + built, a round-trip that has existed since P-001 and doubles the lowering work + (every new fact must be expressible as, *and survive a round-trip through*, + generated `.own` text). Replace it: build the core `Module` AST + (`ast_nodes`) **directly** from facts and call `check_module` — no text, no + re-parse. The seam is already split for this (`ownir.py`: *"builds a Module and + calls `__main__.check_module` directly … the seam is already split so that + switch is additive, not a rewrite"*). Pin against the existing fixtures: same + findings, one fewer parse. + - **B0b (enabler):** extend OwnIR — and the direct `Module` construction — to carry + per-method **basic blocks + acquire / use / release / move / borrow / return** + operations: the schema the deep checks need. Bumps `OWNIR_VERSION` (a new fact + *category*, NOT additive like P-014's OWN050; the version gate already forces the + extractor and core to move together). +- **B1 / B2 — IDisposable flow for locals.** Lower a method's `new` / `using` / + `.Dispose()` / `return` into acquire / release / escape over a Roslyn CFG → real + **OWN001** (leaked on an exception/early-return path), **OWN002** (use after + dispose), **OWN003** (double dispose) on live C#. The IDisposable story (P-005) — + the most common .NET resource bug, and the first time the core's flow analysis + bites real code. B2 (`using`/try-finally → scoped release) is the smallest first + slice; do it before the general case. +- **B3 — Move / ownership transfer.** `return disposable`, or passing it to a callee + that consumes it → move / escape (P-005 D5). Intraprocedural, driven by declared + signatures, not whole-program tracing. +- **B4 — Borrow / Span.** `Rent` → view → `Return`, `Span`/`ref` aliasing (P-007) — + the borrow checker's crown jewel, hardest on C# (ref structs, Span lifetimes). +- **B5 — Lifetime ordering.** WPF005 escape (`OWN014`) and DI captive (P-006, + partly built) — the hardest; needs an explicit lifetime model. + +**Dependency order:** `B0 → B1 → B3 → B4 / B5`. `A1` is independent but needed for +B1 to cover loopy methods — until A1 lands, a method containing a loop is honestly +skipped (`OWN020`), not guessed. B0+B2 and A1 can proceed in parallel. + +## Non-goals + +- A full C# semantic front-end. We lower the operations the core already models, not + the language. `async`/`await` stays an honest skip (`OWN020`) until a real bug + demands it; whole-program/interprocedural analysis beyond signature-declared + transfer; the XAML/binding engine. The "refuse the soul-eating version" rule holds. +- Rewriting the core. A1 *extends* the existing flow engine (worklist) — it does not + replace the lattice. B0–B5 only feed the core; they decide no verdicts. + +## Sketch + +```text + A1: worklist+fixpoint (loops) ─────────────┐ + v +real *.cs ─[Roslyn CFG + IOperation]─[B1..B5: acquire/use/release/move/borrow] + ─[B0b: per-method ops+blocks OwnIR]─[B0a: build Module directly] + ─[the one core]──> OWN001/002/003/005/014 @ the C# line +``` + +## Relationship to the spec & docs (anti-drift) + +- **`spec/` core semantics: unchanged by Track B.** The core already models + acquire/borrow/move/lifetime for `.own`; B0–B5 *feed* it those facts from C#, they + do not change what it means. **A1 does change the core** (it analyses loops): when + it lands, `spec/OwnCore.md`'s "loops out of scope" (§10) and the `OWN020` + loops clause are updated to describe the worklist — spec follows code. +- **OwnIR contract:** B0b **bumps `OWNIR_VERSION`** — rich per-method facts are a new + category, not an additive optional field (contrast P-014's OWN050, which did not + bump). The load-time version gate already makes a mismatched extractor/core fail + loudly, so the bump is safe by construction. +- **No new diagnostic codes** — B1–B5 reuse the existing OWN001/002/003/005/008/014. + The only catalogue change is *removing* the loops clause from OWN020 when A1 ships. +- **"One checker" preserved:** B0a removes a parse, not a decider; the core remains + the single source of truth. + +## Open questions + +1. **B0b schema shape.** Grow the OwnIR JSON with a per-function ops+blocks array + the bridge maps to a `Module`, or have the extractor emit a serialized `Module` + directly? (Lean: ops+blocks JSON — keeps the extractor decider-free.) +2. **try-finally / `using` modeling.** Map to a "release on all paths" region, or to + explicit release on each CFG exit edge? +3. **How much of Roslyn's `ControlFlowGraph` to consume** (its basic blocks + the + operations we map) vs build our own CFG from syntax. (Lean: consume Roslyn's — it + already normalizes `using`/`try`/short-circuits.) +4. **Loop fixpoint:** does the finite set-of-states lattice converge fast enough + as-is, or is widening warranted on pathological back-edges? (Likely converges.) +5. **Honest-skip granularity:** on an unsupported construct, skip the whole method, + or analyse the modelable prefix and skip the rest? (Conservative: skip the method, + emit `OWN020` once.) +6. **Which slice proves the concept fastest** — B0a+B2 on loop-free methods is the + proposed existential spike; confirm it surfaces a real OWN001 on GTM before + investing in B3–B5. diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 5b4d94da..41cd183b 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -37,9 +37,10 @@ proposal is marked `done` with a pointer. | [P-013](P-013-distribution-surface.md) | Distribution surface (how people run Own.NET) | v0 built (CI/Action + dotnet tool) | | [P-014](P-014-semantic-resolution.md) | Project-local semantic resolution (kills `+=` false positives) | draft (P0) | | [P-015](P-015-configuration-surface.md) | Configuration surface (check selection & per-category severity) | draft (stub) | +| [P-016](P-016-deep-fact-extraction.md) | Deep C# fact extraction (CFG + flow lowering; loops) | draft | > For priorities, milestones, the framing, and the design philosophy across all -> of these, see the strategy hub: [`docs/ROADMAP.md`](../ROADMAP.md). P-004 … P-015 +> of these, see the strategy hub: [`docs/ROADMAP.md`](../ROADMAP.md). P-004 … P-016 > capture ideas raised in design discussion — they are *on the record for > consideration*, drafts, not commitments. diff --git a/frontend/roslyn/OwnSharp.Extractor/OwnSharp.Extractor.csproj b/frontend/roslyn/OwnSharp.Extractor/OwnSharp.Extractor.csproj index 4f3c364d..6678ccf5 100644 --- a/frontend/roslyn/OwnSharp.Extractor/OwnSharp.Extractor.csproj +++ b/frontend/roslyn/OwnSharp.Extractor/OwnSharp.Extractor.csproj @@ -21,7 +21,9 @@ - + diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 7809d148..9466b91b 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -1,16 +1,20 @@ // OwnSharp OwnIR extractor (P-001 v0). // -// Scans C# *source text* (syntax only — no compilation, no references) for the -// event-subscription leak pattern and emits OwnIR facts (JSON) in the OwnLang -// spec's vocabulary. The Python core (`python -m ownlang ownir facts.json`) then -// produces the verdict (OWN001 leak) at the C# location. +// Scans C# and emits OwnIR facts (JSON) in the OwnLang spec's vocabulary; the +// Python core (`python -m ownlang ownir facts.json`) produces the verdict +// (OWN001 leak) at the C# location. // -// Heuristic (docs/proposals/P-001, P-004): a subscription is `target += handler` -// where the right side is a method group (identifier or member access), not e.g. -// `count += 1`. It is "released" if a matching `target -= handler` (same text on -// both sides) exists in the class. A `Tick`/`Elapsed` handler is additionally -// tagged resource=timer (WPF002) and counts as released if the timer's receiver -// also has a `.Stop()` call (e.g. `_timer.Stop()` in Dispose). +// Event subscriptions are resolved type-aware (P-014 Tier A): all inputs are +// parsed into ONE CSharpCompilation with the runtime's framework references, and +// a `target += handler` is a subscription only when the SemanticModel binds the +// left side to an event symbol — so `sum += value` (arithmetic) is not a leak. +// When the left side's declaring type is an unresolved external reference we do +// not guess: a handler-shaped RHS surfaces as an OWN050 "leakage analysis +// skipped" note, never a leak. A subscription is "released" by a matching +// `target -= handler` in the class; a `Tick`/`Elapsed` handler is tagged +// resource=timer (WPF002) and is released if the timer's receiver also has a +// `.Stop()` call. The IDisposable/pool/local detectors remain syntactic for now +// (P-014 rollout: the event fact goes type-aware first). // // Usage: ownsharp-extract [more ...] [-o facts.json] // @@ -26,9 +30,15 @@ var rawInputs = new List(); string? outPath = null; +// Event-subscription detection is on by default now that it is type-aware +// (P-014 Tier A graduates it from the interim off). `--no-event-leaks` opts out +// (e.g. to run only the disposable/pool detectors); it is the first instance of +// the broader check-selection surface tracked in P-015. +bool emitEvents = true; for (int i = 0; i < args.Length; i++) { if (args[i] == "-o" && i + 1 < args.Length) outPath = args[++i]; + else if (args[i] == "--no-event-leaks") emitEvents = false; else rawInputs.Add(args[i]); } @@ -106,6 +116,34 @@ static bool IsTimerEvent(ExpressionSyntax left) => left is MemberAccessExpressionSyntax m && (m.Name.Identifier.Text == "Tick" || m.Name.Identifier.Text == "Elapsed"); +// P-004 self-owned exemption: is the event SOURCE owned by (and so never longer- +// lived than) the subscriber? True for a bare instance event on `this`, or a +// receiver that resolves to a field/local the class constructs (`new`s). Such a +// `source <-> this` reference cycle is GC-collectable, so the subscription is not +// a leak. The receiver is resolved to a SYMBOL (not matched by text), and the +// `constructed` set is AST-based (ObjectCreationExpressionSyntax) — not a regex. +// NOTE: callers must exclude timers — a *running* timer is rooted by the +// dispatcher regardless of who owns the field. +static bool IsSelfOwnedSource(ExpressionSyntax left, IEventSymbol ev, + SemanticModel model, HashSet constructed) +{ + if (left is not MemberAccessExpressionSyntax m) + return !ev.IsStatic; // bare event => an instance event on `this` + if (m.Expression is ThisExpressionSyntax) + return true; + var recv = model.GetSymbolInfo(m.Expression).Symbol; + return (recv is IFieldSymbol or ILocalSymbol) && constructed.Contains(recv.Name); +} + +// P-004 static-handler exemption: a `+= StaticMethod` stores a delegate whose +// Target is null, so no instance is retained — the subscription cannot leak a +// subscriber, however long-lived the source. Only method-group handlers +// (identifier / member access) are judged; lambdas and delegate-typed values may +// capture state and are left as leak candidates. +static bool IsStaticHandler(ExpressionSyntax right, SemanticModel model) => + IsHandler(right) + && model.GetSymbolInfo(right).Symbol is IMethodSymbol { IsStatic: true }; + // The field name an expression refers to: "_f" for `_f` or `this._f`, else null. static string? FieldName(ExpressionSyntax expr) => expr switch { @@ -128,6 +166,10 @@ t is "IDisposable" or "IAsyncDisposable" or "CancellationTokenSource" var components = new List(); +// Parse every input into a syntax tree first (keeping the file path we report +// it under), then build ONE compilation over all of them so the SemanticModel +// resolves cross-file and cross-project symbols (P-014 Tier A). +var parsed = new List<(string file, SyntaxTree tree)>(); foreach (var path in inputs) { // Defensive: an explicit input that is not a readable file (a directory @@ -149,8 +191,28 @@ t is "IDisposable" or "IAsyncDisposable" or "CancellationTokenSource" Console.Error.WriteLine($"ownsharp-extract: skipping unreadable file: {path} ({ex.Message})"); continue; } - var file = Rel(path); - var root = CSharpSyntaxTree.ParseText(text, path: path).GetRoot(); + parsed.Add((Rel(path), CSharpSyntaxTree.ParseText(text, path: path))); +} + +// Project-local compilation (P-014 Tier A): the framework reference set is this +// runtime's trusted platform assemblies — zero-config, on disk wherever `dotnet` +// runs; no third-party / MSBuild references. Enough to resolve primitives, +// in-project types and BCL events; external types (WPF/DevExpress) stay +// unresolved and are surfaced as OWN050 "unchecked", never guessed as leaks. +// Error-tolerant: compile diagnostics are irrelevant — we only read symbols. +var references = ((AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string) ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) + .ToList(); +var compilation = CSharpCompilation.Create( + "own", parsed.Select(p => p.tree), references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + +foreach (var (file, tree) in parsed) +{ + var model = compilation.GetSemanticModel(tree); + var root = tree.GetRoot(); foreach (var cls in root.DescendantNodes().OfType()) { @@ -170,27 +232,9 @@ t is "IDisposable" or "IAsyncDisposable" or "CancellationTokenSource" && m.Name.Identifier.Text == "Stop") stopped.Add(m.Expression.ToString()); - var subs = new List(); - foreach (var a in assigns) - { - if (!a.IsKind(SyntaxKind.AddAssignmentExpression) || !IsHandler(a.Right)) - continue; - var isTimer = IsTimerEvent(a.Left); - var released = unsub.Contains($"{a.Left}|{a.Right}") - || (isTimer && Receiver(a.Left) is { } recv && stopped.Contains(recv)); - subs.Add(new - { - @event = a.Left.ToString(), - handler = a.Right.ToString(), - line = LineOf(a.Left), - released, - resource = isTimer ? "timer" : "subscription", - }); - } - - // WPF003: an IDisposable field the class constructs (`new`) but never - // disposes. Owned (not injected) = assigned a `new` in this class; - // released = a `.Dispose()` call somewhere in the class. + // Fields/locals this class constructs (`new`) — it OWNS them, so their + // lifetime cannot exceed the class's. Used by the self-owned-subscription + // exemption (P-004) just below and by the disposable detector (WPF003). var constructed = new HashSet(); foreach (var fd in cls.Members.OfType()) foreach (var v in fd.Declaration.Variables) @@ -204,6 +248,56 @@ or ImplicitObjectCreationExpressionSyntax && FieldName(a.Left) is { } fn) constructed.Add(fn); + var subs = new List(); + foreach (var a in assigns) + { + if (!emitEvents || !a.IsKind(SyntaxKind.AddAssignmentExpression)) + continue; + // P-014 Tier A: a `+=` is an event subscription only when the LHS binds + // to an event symbol. `sum += value` (a local/field/property) resolves + // to a non-event and is skipped — arithmetic, not a leak. When the LHS + // cannot be resolved (its declaring type is an unreferenced external + // assembly) we do NOT guess a leak: a handler-shaped RHS becomes an + // OWN050 "unchecked" marker that the core surfaces as an advisory note. + var leftSymbol = model.GetSymbolInfo(a.Left).Symbol; + if (leftSymbol is IEventSymbol ev) + { + var isTimer = IsTimerEvent(a.Left); + // P-004 lifetime exemptions — skip, not a leak (timers excluded: a + // running timer is dispatcher-rooted regardless): + // - self-owned source (`this`, or a field/local the class + // constructs) — the source<->this cycle is GC-collectable; + // - static handler — a static method has a null delegate target, + // so no instance is retained and nothing can leak. + if (!isTimer && (IsSelfOwnedSource(a.Left, ev, model, constructed) + || IsStaticHandler(a.Right, model))) + continue; + var released = unsub.Contains($"{a.Left}|{a.Right}") + || (isTimer && Receiver(a.Left) is { } recv && stopped.Contains(recv)); + subs.Add(new + { + @event = a.Left.ToString(), + handler = a.Right.ToString(), + line = LineOf(a.Left), + released, + resource = isTimer ? "timer" : "subscription", + }); + } + else if (leftSymbol is null && IsHandler(a.Right)) + { + subs.Add(new + { + @event = a.Left.ToString(), + handler = a.Right.ToString(), + line = LineOf(a.Left), + resource = "unresolved-subscription", + }); + } + } + + // WPF003: an IDisposable field the class constructs (`new`) but never + // disposes. Owned (not injected) = in `constructed` (computed above); + // released = a `.Dispose()` call somewhere in the class. var disposed = new HashSet(); foreach (var inv in cls.DescendantNodes().OfType()) if (inv.Expression is MemberAccessExpressionSyntax m diff --git a/frontend/roslyn/README.md b/frontend/roslyn/README.md index e47ac4f2..5dc5b4f1 100644 --- a/frontend/roslyn/README.md +++ b/frontend/roslyn/README.md @@ -10,10 +10,14 @@ checks. ## What it does (v0) -Syntax-only (no compilation, no references): finds `target += handler` event -subscriptions and marks each `released` iff a matching `target -= handler` exists -in the same class. Exactly the `event += without -=` leak pattern. The verdict -(OWN001) comes from the core, not from here — there is one checker, not two. +Type-aware (P-014 Tier A): all inputs are parsed into one `CSharpCompilation` with +the runtime's framework references, and a `target += handler` is an event +subscription only when the `SemanticModel` binds the left side to an event — so +`sum += value` (arithmetic) is not a leak. Each is marked `released` iff a matching +`target -= handler` exists in the same class. When the left side's declaring type +is an unresolved external reference, it surfaces as an OWN050 "leakage analysis +skipped" note, never guessed as a leak. Still fact-only and intraprocedural; the +verdict (OWN001) comes from the core, not from here — there is one checker, not two. ## Run @@ -70,6 +74,8 @@ per keystroke) — a conflict with "one checker", not just effort. See This sandbox has no local `dotnet`, so the extractor is built and run only in CI (the `wpf-extractor` job); the Python bridge + core are tested locally -(`tests/test_ownir.py`) against hand-written facts. The heuristic (RHS is a -method group) and non-goals (XAML, timers, IDisposable fields, semantic event -resolution) are tracked in the proposal. +(`tests/test_ownir.py`) against hand-written facts. Event subscriptions are +resolved type-aware (P-014 Tier A); resolving *external* events (WPF/DevExpress) +needs their references (P-014 Tier B, opt-in) — until then they surface as OWN050 +"unchecked" notes. The IDisposable-field / local / pool detectors remain syntactic +for now (P-014 rollout: the event fact goes type-aware first). diff --git a/frontend/roslyn/samples/SampleTypes.cs b/frontend/roslyn/samples/SampleTypes.cs new file mode 100644 index 00000000..b333b55e --- /dev/null +++ b/frontend/roslyn/samples/SampleTypes.cs @@ -0,0 +1,34 @@ +using System; + +// In-sample event sources so the type-aware extractor (P-014 Tier A) can resolve +// the samples' `+=` to real events without referencing WPF or any third-party +// assembly. A real WPF/DevExpress event on an *unreferenced* type would instead +// surface as an OWN050 "leakage analysis skipped" note — that is the honest Tier A +// behavior on a project whose dependencies were not passed (see P-014). + +public interface IEventBus +{ + event EventHandler CustomerChanged; + event EventHandler OrdersChanged; +} + +// A small event source a view-model can construct and own (used by the self-owned +// exemption sample): subscribing to an owned field's event is not a leak. +public sealed class Calc +{ + public event EventHandler? Changed; + public static event EventHandler? GlobalPing; +} + +namespace WpfApp +{ + // A stand-in for System.Windows.Threading.DispatcherTimer (WPF is not on the + // Tier A reference set): the same Tick / Start / Stop surface the timer sample + // exercises, so `_timer.Tick += OnTick` binds to a real event symbol. + public sealed class DispatcherTimer + { + public event EventHandler? Tick; + public void Start() { } + public void Stop() { } + } +} diff --git a/frontend/roslyn/samples/SelfOwnedViewModel.cs b/frontend/roslyn/samples/SelfOwnedViewModel.cs new file mode 100644 index 00000000..c5d9e33e --- /dev/null +++ b/frontend/roslyn/samples/SelfOwnedViewModel.cs @@ -0,0 +1,19 @@ +using System; + +// NOT a leak (P-004 self-owned exemption): the event source `_calc` is a field +// this class constructs and therefore OWNS — its lifetime cannot exceed the +// view-model's, so the `_calc <-> this` reference cycle is collectable by the GC +// even with no `-=`. The extractor must stay SILENT here, unlike CustomerViewModel +// where the source is an injected (longer-lived) bus. Timers are the exception +// (a running timer is dispatcher-rooted regardless of ownership) — see TimerViewModel. +public sealed class SelfOwnedViewModel +{ + private readonly Calc _calc = new(); + + public SelfOwnedViewModel() + { + _calc.Changed += OnChanged; // self-owned source -> not a leak, no finding + } + + private void OnChanged(object? sender, EventArgs e) { } +} diff --git a/frontend/roslyn/samples/StaticHandlerViewModel.cs b/frontend/roslyn/samples/StaticHandlerViewModel.cs new file mode 100644 index 00000000..921a969a --- /dev/null +++ b/frontend/roslyn/samples/StaticHandlerViewModel.cs @@ -0,0 +1,16 @@ +using System; + +// NOT a leak (P-004 static-handler exemption): the handler is a STATIC method, so +// the stored delegate's Target is null — no instance is retained, however +// long-lived the source (here a static event that lives the whole program). The +// extractor must stay SILENT, unlike an instance-method handler which would pin +// its owner. Contrast: CustomerViewModel (instance handler → leak). +public sealed class StaticHandlerViewModel +{ + public StaticHandlerViewModel() + { + Calc.GlobalPing += OnGlobalPing; // static handler -> null target, no leak + } + + private static void OnGlobalPing(object? sender, EventArgs e) { } +} diff --git a/frontend/roslyn/samples/TimerViewModel.cs b/frontend/roslyn/samples/TimerViewModel.cs index fa559766..0b7d93c5 100644 --- a/frontend/roslyn/samples/TimerViewModel.cs +++ b/frontend/roslyn/samples/TimerViewModel.cs @@ -1,10 +1,10 @@ using System; -using System.Windows.Threading; namespace WpfApp; -// A DispatcherTimer whose Tick handler is never detached and the timer is never -// stopped: the running timer keeps this view-model alive. The core reports +// A DispatcherTimer (the in-sample stand-in from SampleTypes.cs — WPF is not on +// the Tier A reference set) whose Tick handler is never detached and the timer is +// never stopped: the running timer keeps this view-model alive. The core reports // OWN001 [resource: timer] at the `+=` line. public sealed class TimerViewModel { diff --git a/ownlang/__main__.py b/ownlang/__main__.py index 56263b91..06ac8e33 100644 --- a/ownlang/__main__.py +++ b/ownlang/__main__.py @@ -13,6 +13,9 @@ `--severity` (ownir only) picks how the host shows a finding — `error` (default, fails a build / red check) or `warning` (advisory). It is a presentation choice; the finding is still the core's verdict. +`--verbosity` (ownir only) is `quiet` (errors only — hide the advisory OWN050 +"leakage analysis skipped" notes, P-014 Tier A), `normal` (default), or `verbose` +(also print a per-code breakdown). Exit code is non-zero if any error-level diagnostic was produced. """ @@ -188,11 +191,14 @@ def _read(path: str) -> str: return f.read() -def cmd_ownir(path: str, fmt: str = "human", severity: str = "error") -> int: +def cmd_ownir(path: str, fmt: str = "human", severity: str = "error", + verbosity: str = "normal") -> int: """Check OwnIR facts (extracted from real C# by the Roslyn frontend) through the same core, surfacing findings at their C# locations (P-001). `fmt` selects the surface: human (CLI), github (CI annotations), msbuild (VS); - `severity` picks how the host shows them (error/warning).""" + `severity` picks how the host shows them (error/warning); `verbosity` is + `quiet` (errors only — hide the advisory OWN050 notes), `normal` (default), or + `verbose` (also print a per-code breakdown).""" from .ownir import OwnIRError, check_facts, load, render_finding try: findings = check_facts(load(path)) @@ -205,17 +211,34 @@ def cmd_ownir(path: str, fmt: str = "human", severity: str = "error") -> int: # pollute that stream. machine = fmt in {"github", "msbuild"} summary_to = sys.stderr if machine else sys.stdout - for f in findings: - print(render_finding(f, fmt, severity)) - if not findings: + # OWN050 "leakage analysis skipped" notes are advisory (P-014 Tier A): always + # shown as warnings regardless of --severity, and never affect the exit code — + # they are coverage notes ("we could not check this"), not verdicts. + leaks = [f for f in findings if not f.advisory] + notes = [f for f in findings if f.advisory] + shown = leaks if verbosity == "quiet" else findings + for f in shown: + print(render_finding(f, fmt, "warning" if f.advisory else severity)) + if not shown: print(f"{path}: ok — no subscription leaks found", file=summary_to) - n = len(findings) - print(f"\n{n} finding{'s' if n != 1 else ''}.", file=summary_to) - return 1 if findings else 0 + n = len(leaks) + summary = f"\n{n} finding{'s' if n != 1 else ''}" + if notes: + summary += (f" ({len(notes)} unchecked hidden)" if verbosity == "quiet" + else f", {len(notes)} unchecked (OWN050)") + print(summary + ".", file=summary_to) + if verbosity == "verbose" and findings: + by_code: dict[str, int] = {} + for f in findings: + by_code[f.code] = by_code.get(f.code, 0) + 1 + breakdown = ", ".join(f"{c}={by_code[c]}" for c in sorted(by_code)) + print(f" by code: {breakdown}", file=summary_to) + return 1 if leaks else 0 _FORMATS = {"human", "github", "msbuild"} _SEVERITIES = {"error", "warning"} +_VERBOSITY = {"quiet", "normal", "verbose"} def main(argv: list[str]) -> int: @@ -223,10 +246,10 @@ def main(argv: list[str]) -> int: print(__doc__) return 2 cmd = argv[0] - # Pull the optional value-flags (`--format`/`--severity`, ownir only) out of - # the arguments in either `--flag V` or `--flag=V` form; everything else is - # positional. Keeps the other commands' single positional-path contract. - opts = {"--format": "human", "--severity": "error"} + # Pull the optional value-flags (`--format`/`--severity`/`--verbosity`, ownir + # only) out of the arguments in either `--flag V` or `--flag=V` form; everything + # else is positional. Keeps the other commands' single positional-path contract. + opts = {"--format": "human", "--severity": "error", "--verbosity": "normal"} seen_value_flags = False positional: list[str] = [] rest = argv[1:] @@ -255,7 +278,8 @@ def main(argv: list[str]) -> int: if len(positional) != 1: print(__doc__) return 2 - fmt, severity = opts["--format"], opts["--severity"] + fmt, severity, verbosity = (opts["--format"], opts["--severity"], + opts["--verbosity"]) if fmt not in _FORMATS: print(f"unknown --format {fmt!r} (choose: {', '.join(sorted(_FORMATS))})", file=sys.stderr) @@ -264,15 +288,20 @@ def main(argv: list[str]) -> int: print(f"unknown --severity {severity!r} (choose: " f"{', '.join(sorted(_SEVERITIES))})", file=sys.stderr) return 2 - # `--format`/`--severity` are ownir-only — reject them on other commands by - # *presence*, not just non-default value (so `check x --format human` is a - # clear error, not a silent no-op). + if verbosity not in _VERBOSITY: + print(f"unknown --verbosity {verbosity!r} (choose: " + f"{', '.join(sorted(_VERBOSITY))})", file=sys.stderr) + return 2 + # `--format`/`--severity`/`--verbosity` are ownir-only — reject them on other + # commands by *presence*, not just non-default value (so `check x --format + # human` is a clear error, not a silent no-op). if cmd != "ownir" and seen_value_flags: - print("--format/--severity only apply to `ownir`", file=sys.stderr) + print("--format/--severity/--verbosity only apply to `ownir`", + file=sys.stderr) return 2 path = positional[0] if cmd == "ownir": - return cmd_ownir(path, fmt, severity) + return cmd_ownir(path, fmt, severity, verbosity) return {"check": cmd_check, "emit": cmd_emit, "cfg": cmd_cfg, "report": cmd_report}[cmd](path) diff --git a/ownlang/diagnostics.py b/ownlang/diagnostics.py index ad9ebe3e..12710994 100644 --- a/ownlang/diagnostics.py +++ b/ownlang/diagnostics.py @@ -6,6 +6,7 @@ 020 unsupported construct (loops / async) 030-034 name resolution & structural 040-041 extern / call-boundary + 050 C# front-end resolution coverage (P-014; advisory, never a verdict) The split between *definite* (002 use-after-release, 005 use-after-move) and *maybe* (009, 010) codes is deliberate: a fault that holds on every path is a @@ -68,6 +69,8 @@ class Severity(Enum): # ---- extern / call boundary ---- "OWN040": "call to an undeclared function (unknown calls are forbidden)", "OWN041": "call argument mismatch", + # ---- C# front-end resolution coverage (P-014; advisory) ---- + "OWN050": "declaring type unresolved — leakage analysis skipped", } diff --git a/ownlang/ownir.py b/ownlang/ownir.py index 466bfce9..66fbb260 100644 --- a/ownlang/ownir.py +++ b/ownlang/ownir.py @@ -44,6 +44,10 @@ tag `[resource: disposable]`. - "pool": an `ArrayPool`/`MemoryPool` buffer `Rent`ed but never `Return`ed; tag `[resource: pooled buffer]`. + - "unresolved-subscription": a `+=` whose left side could not be bound to an + event (its declaring type is an unreferenced external assembly). NOT an owned + resource — it is skipped by the lowering and surfaced separately as an + advisory OWN050 "leakage analysis skipped" note, never a leak (P-014 Tier A). An unreleased entry is the core's OWN001 (owned-but-not-released) at the C# `line`. The `resource`/`type` fields are additive and optional, so they do NOT @@ -147,6 +151,9 @@ class Finding: handler: str message: str kind: str = "subscription token" + # an advisory note (e.g. OWN050 "leakage analysis skipped") rather than a leak + # verdict: rendered as a warning and excluded from the exit code. + advisory: bool = False def render(self, severity: str = "error") -> str: return (f"{self.file}:{self.line}: {severity}: [{self.code}] " @@ -271,6 +278,12 @@ def to_own(facts: dict[str, Any]) -> tuple[str, dict[str, dict[str, Any]]]: for sub in subscriptions: if not isinstance(sub, dict): raise OwnIRError("each subscription must be a JSON object") + # An "unresolved-subscription" marker is not an owned resource (the + # extractor could not bind the LHS to an event). Do not lower it to an + # acquire — that would become a phantom OWN001 leak. It is surfaced as + # an advisory OWN050 note by _unresolved_findings instead. + if sub.get("resource") == "unresolved-subscription": + continue handle = f"sub_{gid}" gid += 1 handles[handle] = {**sub, "component": cname, @@ -376,6 +389,11 @@ def check_facts(facts: dict[str, Any]) -> list[Finding]: # it (see ownlang/di.py). Findings carry the registration site as file/line. findings.extend(_di_findings(facts)) + # OWN050 (P-014 Tier A): a `+=` whose declaring type could not be resolved — + # an advisory "leakage analysis skipped" note, never a leak. Routed through + # this side path so it bypasses the ERROR-only diagnostic mapping above. + findings.extend(_unresolved_findings(facts)) + findings.sort(key=lambda f: (f.file, f.line, f.code)) return findings @@ -410,3 +428,38 @@ def _di_findings(facts: dict[str, Any]) -> list[Finding]: message=c.message, kind="DI lifetime") for c in find_captive_dependencies(services) ] + + +def _unresolved_findings(facts: dict[str, Any]) -> list[Finding]: + """Surface every "unresolved-subscription" marker as an advisory OWN050 + finding (P-014 Tier A): the extractor saw a `+=` that looks like an event + subscription but could not bind the left side to an event — its declaring + type is an unreferenced external assembly. We do not guess a leak; we say, + honestly, that it was not checked. Advisory: rendered as a warning and + excluded from the exit code (see __main__.cmd_ownir).""" + out: list[Finding] = [] + comps = facts.get("components", []) + if not isinstance(comps, list): + return out + for comp in comps: + if not isinstance(comp, dict): + continue + cfile = comp.get("file", "?") + cname = comp.get("name", "?") + subs = comp.get("subscriptions", []) + if not isinstance(subs, list): + continue + for sub in subs: + if not isinstance(sub, dict) or \ + sub.get("resource") != "unresolved-subscription": + continue + event = sub.get("event", "?") + handler = sub.get("handler", "?") + message = (f"cannot verify '{event}' — its declaring type is an " + f"unresolved reference (build the project or pass " + f"references); leakage analysis skipped") + out.append(Finding( + file=cfile, line=_as_int(sub.get("line", 0)), code="OWN050", + component=cname, event=event, handler=handler, message=message, + kind="unresolved reference", advisory=True)) + return out diff --git a/scripts/own-check.ps1 b/scripts/own-check.ps1 index 1a49925b..54b1dff6 100644 --- a/scripts/own-check.ps1 +++ b/scripts/own-check.ps1 @@ -21,6 +21,11 @@ .PARAMETER Severity How a host shows findings: error (default) or warning (advisory). +.PARAMETER Verbosity + How much to print: quiet (errors only — hide the advisory OWN050 "leakage + analysis skipped" notes, P-014 Tier A), normal (default), or verbose (also a + per-code breakdown). + .PARAMETER FailOnFinding Exit non-zero (the core's code) when any leak is found. @@ -37,6 +42,8 @@ param( [string]$Root, [string]$Format = "human", [string]$Severity = "error", + [ValidateSet("quiet", "normal", "verbose")] + [string]$Verbosity = "normal", [switch]$FailOnFinding, [Parameter(ValueFromRemainingArguments = $true)] [string[]]$Paths @@ -64,7 +71,9 @@ try { # Stage 2: the one checker produces the verdict at the C# location. $env:PYTHONPATH = $Root - & python -m ownlang ownir $facts.FullName --format $Format --severity $Severity + $ownirArgs = @($facts.FullName, "--format", $Format, "--severity", $Severity, + "--verbosity", $Verbosity) + & python -m ownlang ownir @ownirArgs $rc = $LASTEXITCODE } finally { diff --git a/spec/Diagnostics.md b/spec/Diagnostics.md index 7c875058..4e7ed015 100644 --- a/spec/Diagnostics.md +++ b/spec/Diagnostics.md @@ -68,6 +68,20 @@ See [BufferPolicies.md](BufferPolicies.md). | OWN040 | call to an undeclared function (unknown calls are forbidden) | | OWN041 | call argument mismatch (arity / effect / plain-vs-resource) | +## C# front-end resolution coverage (P-014) + +Advisory only — a *coverage note*, never a verdict (this is the "noted" exception +to the `error`-by-default rule above). Emitted by the OwnIR bridge (not the core +lattice) when the type-aware C# extractor ([P-014](../docs/proposals/P-014-semantic-resolution.md)) +sees a `+=` that looks like an event subscription but cannot bind its left side to +an event — its declaring type is an unreferenced external assembly. We do not +guess a leak; we report, honestly, that it was not checked. Rendered as a +`warning` regardless of `--severity` and excluded from the exit code. + +| Code | Title | +|------|-------| +| OWN050 | declaring type unresolved — leakage analysis skipped | + ## Rendering The CLI renders rustc-style: `file:line:col`, the source line, and a caret under diff --git a/tests/fixtures/ownir/unresolved.facts.json b/tests/fixtures/ownir/unresolved.facts.json new file mode 100644 index 00000000..37ab2b70 --- /dev/null +++ b/tests/fixtures/ownir/unresolved.facts.json @@ -0,0 +1,24 @@ +{ + "ownir_version": 0, + "module": "TierA", + "components": [ + { + "name": "GridViewModel", + "file": "GridViewModel.cs", + "subscriptions": [ + { + "event": "grid.View.CellValueChanged", + "handler": "OnCellChanged", + "line": 20, + "resource": "unresolved-subscription" + }, + { + "event": "vm.PropertyChanged", + "handler": "OnProp", + "line": 22, + "released": false + } + ] + } + ] +} diff --git a/tests/test_ownir.py b/tests/test_ownir.py index 93add257..1d0ce382 100644 --- a/tests/test_ownir.py +++ b/tests/test_ownir.py @@ -50,6 +50,8 @@ "ownir", "local_disposable.facts.json") _DI_FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "ownir", "di.facts.json") +_UNRESOLVED_FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", + "ownir", "unresolved.facts.json") def _write_facts(obj: dict) -> str: @@ -295,6 +297,43 @@ def run() -> int: "line": "NaN"}]}): fails.append("a non-integer service line did not raise OwnIRError") + # --- P-014 Tier A: an "unresolved-subscription" marker (the extractor could + # not bind the `+=` LHS to an event) is NOT a leak — the lowering skips it + # (no phantom OWN001) and it surfaces as an advisory OWN050 note; a real + # subscription in the same component still leaks (OWN001, non-advisory). + with open(_UNRESOLVED_FIXTURE, encoding="utf-8") as f: + ufacts = json.load(f) + usrc, _ = to_own(ufacts) + checks += 1 + try: + parse(usrc) + except Exception as e: + fails.append(f"lowered unresolved facts do not parse: {e}") + # the marker must NOT be lowered to an acquire (else it becomes a phantom leak) + checks += 1 + if usrc.count("acquire Subscription") != 1: + fails.append(f"unresolved marker was lowered to an acquire: {usrc!r}") + ufindings = check_facts(ufacts) + unotes = [x for x in ufindings if x.code == "OWN050"] + uleaks = [x for x in ufindings if x.code == "OWN001"] + checks += 1 + if len(unotes) != 1 or not unotes[0].advisory: + fails.append(f"expected 1 advisory OWN050 note, got " + f"{[(x.code, x.advisory) for x in ufindings]}") + else: + u0 = unotes[0] + checks += 1 + if (u0.file, u0.line) != ("GridViewModel.cs", 20): + fails.append(f"wrong OWN050 location: {u0.file}:{u0.line}") + if "leakage analysis skipped" not in u0.message: + fails.append(f"OWN050 message wrong: {u0.message!r}") + if "[resource: unresolved reference]" not in u0.render(): + fails.append(f"OWN050 missing kind tag: {u0.render()!r}") + checks += 1 + if len(uleaks) != 1 or uleaks[0].advisory: + fails.append(f"expected 1 real OWN001 leak (non-advisory), got " + f"{[(x.code, x.advisory) for x in ufindings]}") + # --- output surfaces (Уровень 1): the same finding renders for a human, a # GitHub annotation, and an MSBuild/VS Error List line. The format lives # in the core (one checker), so the Action/script stay thin wrappers.