From 3a9b739a6f63e5894ec09d1989ba5574f9f6609e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:31:02 +0000 Subject: [PATCH 1/2] =?UTF-8?q?docs(notes):=20state=20protocols=20x=20effe?= =?UTF-8?q?ct=20summaries=20=E2=80=94=20harmless-call=20wire=20is=20missin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kill-first finding at main a27a827: the only solved summary (MOS / P-037 guarded transfer) cannot prove a call harmless inside a protocol region. Non-disposable methods get no record, borrow_mut collapses into borrow, and the escapes axis has no producer, so a writer and an escaper both solve to transfer=no. Deferring the decision to the core needs a must-understand OwnIR construct (IR3/IR4), i.e. a version bump. Records the missing contract (HeapEffectSummary -> Harmless(callee)) and proposes H0 (inert heap-effect summary domain, facts-only sidecar, no verdict change) and H1 (the wire, pending an owner ruling on OwnIR). No code, fixture, freeze or verdict changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFvhLx1fM9Gerg4dKsZcL --- .../protocol-harmless-call-summary-gap.md | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/notes/protocol-harmless-call-summary-gap.md diff --git a/docs/notes/protocol-harmless-call-summary-gap.md b/docs/notes/protocol-harmless-call-summary-gap.md new file mode 100644 index 00000000..a81bcddc --- /dev/null +++ b/docs/notes/protocol-harmless-call-summary-gap.md @@ -0,0 +1,149 @@ +# State protocols × effect summaries: the missing wire for harmless calls in a region + +Status: **BLOCKED BY SUMMARY INFRASTRUCTURE** — a kill-first finding, no code +change. Measured at `main` = `a27a8277b40309aa56b945e73dd305ddb90e72b4`. + +Question: can `ProtocolLowering` let a *provably harmless* call stand inside a +state-protocol region by consuming the existing P-036/P-037 summary layer, +without weakening the fail-closed default? + + unknown / no summary -> refusal (as today) + summary proves harmless -> allowed + summary may mutate / escape the entity -> refusal or verdict, never clean + +Answer: **not with what exists.** The only summary the repository computes +does not carry the property, and there is no channel through which the lowering +could consult a core-side summary without an OwnIR vocabulary change. Building +the proof inside the extractor would be the typestate-only purity analyzer this +work must not create. What follows is the exact gap and the smallest slice that +closes it. + +## 1. What exists + +| Layer | What it summarizes | Can it prove "harmless"? | +|---|---|---| +| MOS (`ownlang/ownership.py`, `own-analysis` port), P-005 D5 | `Transfer ∈ {no, must, may, unknown}` per **disposable** parameter, plus owned-return kind | **No.** Non-disposable methods get no record; `borrow` and `borrow_mut` both seed `borrow` (`_build_skeletons`); the `escapes` axis is reserved with **no producer** (TZ D2); no heap/static/receiver effect at all | +| P-037 guarded transfer (A1 in progress; A2.1 sidecar inert) | The same `Transfer` lattice, split on one bool/null guard | **No.** A refinement of the transfer axis, not a new axis | +| P-036 `ParameterEffect = … BorrowMut \| MayEscape \| Unknown`, `FieldEffects`, region `Escapes(...)` | Named in the design (ownership/region summary sections) | Would be enough — but it is **design only**, scheduled under P-036 Phase 5 ("#122 cross-method exclusivity"), and has no producer, solver or schema | +| `ProtocolLowering.CheckBoundary.Effects` (extractor) | Writes to the protocol's *entity family* and the family methods reached, for **admission** | Not a candidate: protocol-local, blind to statics, and extending it into a general effect proof is exactly the purity-analyzer-for-typestate this work rules out | + +### Kill-first probe (reproducible) + +Sample API (`frontend/roslyn/protocol-samples/Api`) plus: + +```csharp +public static class Helpers +{ + private static int _counter; + private static Order? _kept; + public static int Twice(int x) => x * 2; // harmless + public static void Mutates(Order o) => o.Annotate("x"); // mutates the entity + public static void Escapes(Order o) => _kept = o; // escapes the entity + public static void TouchesGlobalState() => _counter++; // global write, no entity +} +// each inside its own Protocol.WithApproved(order, approved => { ; approved.Ship(); }); +// plus Console.WriteLine("shipping") as the unknown external call +``` + +Extractor at `a27a827` (`--flow-locals`): exit **2**, refusals at +`Twice`, `TouchesGlobalState`, `Console.WriteLine` ("runs code with no stated +contract"). `Mutates(order)` / `Escapes(order)` are not refused: the entity +argument lowers as `use order`, and the core rejects it with a verdict. So the +fail-closed contract holds today, and the one case the slice wants to open +(`Twice`) is refused for the right reason: nothing proves it harmless. + +The same helpers run through the MOS layer (`ownsharp-extract … --flow-locals` +then `python -m ownlang summaries`), with disposable twins added because MOS +sees nothing else: + +| Method | MOS summary | +|---|---| +| `Twice(int)`, `Mutates(Order)`, `Escapes(Order)`, `TouchesGlobalState()` | **no record** (no disposable) | +| `ReadsStream(Stream s) { var n = s.Length; }` | **no record** | +| `WritesStream(Stream s) { s.WriteByte(1); }` | `transfer: no` | +| `EscapesStream(Stream s) { _keptStream = s; }` | `transfer: no` | + +A writer and an escaper solve to the same value a reader would. Using MOS as +the proof would allow `EscapesStream`-shaped calls into a region and call them +clean. **MOS cannot be the proof, by construction**: `no` means "ownership did +not leave the caller", not "nothing was touched". + +## 2. Why the decision cannot simply move into the core + +The summaries are solved in the core, from facts; the refusal happens in the +extractor, before any fact exists. Deferring the decision means lowering the +region call as something the core must check. The OwnIR rules close both +spellings at v1 (spec/OwnIR.md §2, IR3/IR4): + +- a new op (`opaque_call`, say) is a flow-op vocabulary change — **version bump**; +- a plain `call` with an additive `requires: harmless` field changes what `call` + means inside `borrow_mut`; a core that does not read the field (or reads it + before the summary domain exists) sees a call to an extern with no tracked + argument — i.e. **nothing** — and `TouchesGlobalState` becomes clean. That is + fail-open, which the additive rule exists to forbid. + +So two things are missing, and the second is not this work's to decide. + +## 3. The missing contract + + ProtocolLowering (or the core on its behalf) needs, per call site in a region: + + callee — resolved, first-party source, statically bound + (non-virtual, or sealed / devirtualized; else Unknown) + -> solved HeapEffectSummary (a P-036 summary domain, SCC fixpoint, + Unknown absorbing, Python + Rust parity) + -> Harmless(callee) := + every parameter ∈ {Plain, Borrow} (no BorrowMut, no MayEscape) + ∧ receiver ∈ {none, Borrow} + ∧ FieldEffects.write = ∅ (no static, field, array-element + or ref/out write, transitively) + ∧ return is not AliasOf(parameter | heap) unless its type is inert + ∧ closed: no Unknown / extern / unresolved-virtual callee in the closure + absent summary or any Unknown -> refusal, exactly as today + +Notes on the predicate: + +- It is a property of **all** parameters, not just disposable ones, and of + **statics** — the `Backdoor.AnnotateLast()` shape (R9) reaches the entity with no + argument at all. A call is never allowed because it "does not take the entity". +- Reads are allowed (an exclusive region forbids change, not observation); a read + that *yields* an entity-typed value is still caught by the lowering's + exclusivity-by-type rule on the call's result. +- Arguments keep today's lowering: an entity-typed argument is still a `use` of + the entity (a verdict), whatever the summary says. The summary only replaces + the *refusal* of a call that touched nothing visible. + +## 4. Minimal next slice + +**H0 — heap-effect summary domain, inert.** No verdict change, no OwnIR bump. +Precedent: P-037 A2.1 ("emit the guarded-fact sidecar from the Roslyn +extractor, validated and inert"). + +1. Extractor: an additive optional sidecar of per-method **local** heap-effect + evidence for source methods — writes to statics / fields / array elements / + ref-out params, stores of a parameter into the heap, direct callees (with + `virtual`/extern flagged). Facts only (IR6): no solving in C#. +2. Core (Python reference + Rust port): a second domain in the summary envelope, + solved by the same SCC condensation as MOS, `Unknown` absorbing; serialized in + `own summaries` under its own key so the MOS parity artifact is untouched. +3. Acceptance — the probe above as fixtures: `Twice` → harmless; + `Mutates` → `BorrowMut(0)`; `Escapes` → `MayEscape(0)` + static write; + `TouchesGlobalState` → static write; `Console.WriteLine` → Unknown; a + recursive pure pair → harmless; a pure helper calling an unknown → Unknown. + Python/Rust parity fixture; `tests/run_tests.py`; Rust fmt/clippy/tests. + Nothing reads the domain yet, so every existing verdict and refusal is + byte-identical. + +**H1 — the wire (needs an owner ruling first).** Region calls with no visible +entity touch lower to a must-understand construct instead of a refusal; the core +applies `Harmless(callee)` and emits a refusal-equivalent error otherwise. This +is verdict-changing (a refusal becomes clean) and needs one of: an OwnIR v2 op, +or an explicitly ruled exception to IR3 — both outside this task's limits. Until +H1, `refused/R9_opaque_call_inside_region` and the probe's `Twice` case stay +refused. + +## 5. Untouched + +OwnIR version, the token/region model, the trust boundary, OWN053, T0/perf, the +DB-side known gaps, the unknown-call fail-closed default, and the P-022 / P-037 +freezes and amendments: this note changes no code and no fixture. From c865462eb473de76e9ee85a255d26f08104febc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:20:06 +0000 Subject: [PATCH 2/2] feat(heap-effects): H0 inert heap-effect summary sidecar (extractor facts + Python/Rust solver) A second summary domain beside MOS, produced and solved end to end and read by nothing that decides a verdict or a refusal. - Extractor: `--heap-effects FILE` writes heap-effect SOURCE FACTS to a separate file after the facts document is final (HeapEffectFacts.cs). Default-deny IOperation walk: sources, derefs, writes, stores, returns, calls (callee/dispatch/args), locals, unknown reasons. No solving. - Core: ownlang/heap_effects.py (reference, `python -m ownlang.heap_effects`) and own-bridge heap_effects.rs (`own_bridge::dump_heap_effects`): least fixpoint over the SCC condensation; effects plain < borrow < borrow_mut < may_escape < unknown, writes {instance,static,indirect} none < may < unknown, return aliases; Unknown absorbs. - Parity: tests/fixtures/heap_effects (10 cases, 16 rejection texts), byte-exact on both engines; 30 pinned kill-fixture summaries. - Inertness: scripts/heap_effects_gate.py (CI state-protocols job) proves the facts bytes, exit code and stderr do not move with the flag. - No OwnIR version, verdict, ProtocolLowering, P-037 or diagnostic change. The p022 coordinate census is regenerated for the new fixture family. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFvhLx1fM9Gerg4dKsZcL --- .github/workflows/ci.yml | 5 + docs/generated/p022-coord-census.md | 12 +- docs/notes/heap-effect-summaries.md | 113 ++ .../protocol-harmless-call-summary-gap.md | 3 + .../OwnSharp.Extractor/HeapEffectFacts.cs | 953 +++++++++++++++ frontend/roslyn/OwnSharp.Extractor/Program.cs | 12 + .../heap-effects-samples/HeapEffects.cs | 152 +++ ownlang/heap_effects.py | 516 ++++++++ rust/crates/own-bridge/src/dump.rs | 2 +- rust/crates/own-bridge/src/heap_effects.rs | 825 +++++++++++++ rust/crates/own-bridge/src/lib.rs | 16 + rust/crates/own-bridge/tests/heap_effects.rs | 161 +++ scripts/heap_effects_gate.py | 152 +++ .../heap_effects/arity_mismatch.rejected.txt | 1 + .../heap_effects/arity_mismatch.sidecar.json | 50 + .../heap_effects/bad_dispatch.rejected.txt | 1 + .../heap_effects/bad_dispatch.sidecar.json | 29 + .../heap_effects/bad_version.rejected.txt | 1 + .../heap_effects/bad_version.sidecar.json | 4 + .../heap_effects/bad_write_kind.rejected.txt | 1 + .../heap_effects/bad_write_kind.sidecar.json | 25 + .../heap_effects/bool_line.rejected.txt | 1 + .../heap_effects/bool_line.sidecar.json | 20 + .../heap_effects/bool_version.rejected.txt | 1 + .../heap_effects/bool_version.sidecar.json | 4 + .../constructor_result.sidecar.json | 127 ++ .../constructor_result.summaries.json | 77 ++ .../heap_effects/dispatch_kinds.sidecar.json | 73 ++ .../dispatch_kinds.summaries.json | 39 + .../heap_effects/duplicate_key.rejected.txt | 1 + .../heap_effects/duplicate_key.sidecar.json | 35 + .../heap_through_callee.sidecar.json | 109 ++ .../heap_through_callee.summaries.json | 59 + .../heap_effects/huge_index.rejected.txt | 1 + .../heap_effects/huge_index.sidecar.json | 26 + .../heap_effects/local_cycle.sidecar.json | 49 + .../heap_effects/local_cycle.summaries.json | 25 + tests/fixtures/heap_effects/manifest.json | 96 ++ .../methods_not_array.rejected.txt | 1 + .../methods_not_array.sidecar.json | 4 + .../heap_effects/missing_callee.sidecar.json | 39 + .../missing_callee.summaries.json | 27 + .../heap_effects/missing_field.rejected.txt | 1 + .../heap_effects/missing_field.sidecar.json | 19 + .../non_canonical_token.rejected.txt | 1 + .../non_canonical_token.sidecar.json | 28 + .../heap_effects/not_json.rejected.txt | 1 + .../heap_effects/not_json.sidecar.json | 1 + .../heap_effects/not_object.rejected.txt | 1 + .../heap_effects/not_object.sidecar.json | 4 + .../params_out_of_sequence.rejected.txt | 1 + .../params_out_of_sequence.sidecar.json | 26 + .../receiver_mapping.sidecar.json | 127 ++ .../receiver_mapping.summaries.json | 72 ++ .../receiver_on_static.rejected.txt | 1 + .../receiver_on_static.sidecar.json | 22 + .../heap_effects/samples.sidecar.json | 1041 +++++++++++++++++ .../heap_effects/samples.summaries.json | 694 +++++++++++ .../scc_return_alias.sidecar.json | 155 +++ .../scc_return_alias.summaries.json | 91 ++ .../token_out_of_range.rejected.txt | 1 + .../token_out_of_range.sidecar.json | 22 + .../unknown_keeps_inert_plain.sidecar.json | 56 + .../unknown_keeps_inert_plain.summaries.json | 56 + .../unknown_root_write.sidecar.json | 44 + .../unknown_root_write.summaries.json | 21 + tests/test_heap_effects_fixtures.py | 230 ++++ 67 files changed, 6559 insertions(+), 5 deletions(-) create mode 100644 docs/notes/heap-effect-summaries.md create mode 100644 frontend/roslyn/OwnSharp.Extractor/HeapEffectFacts.cs create mode 100644 frontend/roslyn/heap-effects-samples/HeapEffects.cs create mode 100644 ownlang/heap_effects.py create mode 100644 rust/crates/own-bridge/src/heap_effects.rs create mode 100644 rust/crates/own-bridge/tests/heap_effects.rs create mode 100644 scripts/heap_effects_gate.py create mode 100644 tests/fixtures/heap_effects/arity_mismatch.rejected.txt create mode 100644 tests/fixtures/heap_effects/arity_mismatch.sidecar.json create mode 100644 tests/fixtures/heap_effects/bad_dispatch.rejected.txt create mode 100644 tests/fixtures/heap_effects/bad_dispatch.sidecar.json create mode 100644 tests/fixtures/heap_effects/bad_version.rejected.txt create mode 100644 tests/fixtures/heap_effects/bad_version.sidecar.json create mode 100644 tests/fixtures/heap_effects/bad_write_kind.rejected.txt create mode 100644 tests/fixtures/heap_effects/bad_write_kind.sidecar.json create mode 100644 tests/fixtures/heap_effects/bool_line.rejected.txt create mode 100644 tests/fixtures/heap_effects/bool_line.sidecar.json create mode 100644 tests/fixtures/heap_effects/bool_version.rejected.txt create mode 100644 tests/fixtures/heap_effects/bool_version.sidecar.json create mode 100644 tests/fixtures/heap_effects/constructor_result.sidecar.json create mode 100644 tests/fixtures/heap_effects/constructor_result.summaries.json create mode 100644 tests/fixtures/heap_effects/dispatch_kinds.sidecar.json create mode 100644 tests/fixtures/heap_effects/dispatch_kinds.summaries.json create mode 100644 tests/fixtures/heap_effects/duplicate_key.rejected.txt create mode 100644 tests/fixtures/heap_effects/duplicate_key.sidecar.json create mode 100644 tests/fixtures/heap_effects/heap_through_callee.sidecar.json create mode 100644 tests/fixtures/heap_effects/heap_through_callee.summaries.json create mode 100644 tests/fixtures/heap_effects/huge_index.rejected.txt create mode 100644 tests/fixtures/heap_effects/huge_index.sidecar.json create mode 100644 tests/fixtures/heap_effects/local_cycle.sidecar.json create mode 100644 tests/fixtures/heap_effects/local_cycle.summaries.json create mode 100644 tests/fixtures/heap_effects/manifest.json create mode 100644 tests/fixtures/heap_effects/methods_not_array.rejected.txt create mode 100644 tests/fixtures/heap_effects/methods_not_array.sidecar.json create mode 100644 tests/fixtures/heap_effects/missing_callee.sidecar.json create mode 100644 tests/fixtures/heap_effects/missing_callee.summaries.json create mode 100644 tests/fixtures/heap_effects/missing_field.rejected.txt create mode 100644 tests/fixtures/heap_effects/missing_field.sidecar.json create mode 100644 tests/fixtures/heap_effects/non_canonical_token.rejected.txt create mode 100644 tests/fixtures/heap_effects/non_canonical_token.sidecar.json create mode 100644 tests/fixtures/heap_effects/not_json.rejected.txt create mode 100644 tests/fixtures/heap_effects/not_json.sidecar.json create mode 100644 tests/fixtures/heap_effects/not_object.rejected.txt create mode 100644 tests/fixtures/heap_effects/not_object.sidecar.json create mode 100644 tests/fixtures/heap_effects/params_out_of_sequence.rejected.txt create mode 100644 tests/fixtures/heap_effects/params_out_of_sequence.sidecar.json create mode 100644 tests/fixtures/heap_effects/receiver_mapping.sidecar.json create mode 100644 tests/fixtures/heap_effects/receiver_mapping.summaries.json create mode 100644 tests/fixtures/heap_effects/receiver_on_static.rejected.txt create mode 100644 tests/fixtures/heap_effects/receiver_on_static.sidecar.json create mode 100644 tests/fixtures/heap_effects/samples.sidecar.json create mode 100644 tests/fixtures/heap_effects/samples.summaries.json create mode 100644 tests/fixtures/heap_effects/scc_return_alias.sidecar.json create mode 100644 tests/fixtures/heap_effects/scc_return_alias.summaries.json create mode 100644 tests/fixtures/heap_effects/token_out_of_range.rejected.txt create mode 100644 tests/fixtures/heap_effects/token_out_of_range.sidecar.json create mode 100644 tests/fixtures/heap_effects/unknown_keeps_inert_plain.sidecar.json create mode 100644 tests/fixtures/heap_effects/unknown_keeps_inert_plain.summaries.json create mode 100644 tests/fixtures/heap_effects/unknown_root_write.sidecar.json create mode 100644 tests/fixtures/heap_effects/unknown_root_write.summaries.json create mode 100644 tests/test_heap_effects_fixtures.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d30d4011..7f00e39e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3348,6 +3348,11 @@ jobs: - name: The protocol gate + both public CLIs on the C#-derived documents if: matrix.os == 'ubuntu-latest' run: python scripts/protocol_gate.py --rust "$PWD/rust/target/debug/own-cli" + # H0 heap-effect summaries (docs/notes/heap-effect-summaries.md): the real extractor + # still emits the committed sidecar, and --heap-effects moves no byte of the facts, + # no exit code and no stderr — over every protocol case and refusal and examples/. + - name: Heap-effect sidecar (samples + inertness of --heap-effects) + run: python scripts/heap_effects_gate.py # P-012 slice 1: score the checker against the labeled corpus on REAL C# — not # just the .own reduction tests/test_corpus.py checks. Per case: the bug must be diff --git a/docs/generated/p022-coord-census.md b/docs/generated/p022-coord-census.md index bc8459e7..52110191 100644 --- a/docs/generated/p022-coord-census.md +++ b/docs/generated/p022-coord-census.md @@ -10,8 +10,8 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | measure | value | |------------------------------------|------:| -| JSON files scanned | 588 | -| coordinate slots found | 4013 | +| JSON files scanned | 625 | +| coordinate slots found | 4175 | ## By value class @@ -19,9 +19,9 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i |---|---:|---:| | `above-int32` | 24 | 24 | | `below-1` | 23 | 23 | -| `bool` | 17 | 17 | +| `bool` | 18 | 17 | | `float` | 3 | 3 | -| `in-domain` | 3319 | 2193 | +| `in-domain` | 3480 | 2193 | | `negative` | 26 | 26 | | `null` | 259 | 9 | | `outside-int64` | 15 | 15 | @@ -156,6 +156,10 @@ Value classes follow the cp1 taxonomy's axis rather than blurring it: `outside-i | `(root)` | `render_cases[].diagnostic.line` | `in-domain` | — | 26 | 1 | — | | `cli_ownir/inputs` | `components[].subscriptions[].line` | `in-domain` | yes | 3 | 3 | — | | `cli_ownir/inputs/pa th ünïcødé` | `components[].subscriptions[].line` | `in-domain` | yes | 1 | 1 | — | +| `heap_effects` | `methods[].calls[].line` | `in-domain` | — | 37 | 10 | — | +| `heap_effects` | `methods[].line` | `bool` | — | 1 | 1 | `True` | +| `heap_effects` | `methods[].line` | `in-domain` | — | 68 | 20 | — | +| `heap_effects` | `summaries[].line` | `in-domain` | — | 56 | 10 | — | | `lowered` | `components[].subscriptions[].line` | `in-domain` | yes | 24 | 10 | — | | `lowered` | `functions[].[].column` | `in-domain` | yes | 2 | 2 | — | | `lowered` | `functions[].[].line` | `in-domain` | yes | 903 | 112 | — | diff --git a/docs/notes/heap-effect-summaries.md b/docs/notes/heap-effect-summaries.md new file mode 100644 index 00000000..9f3af5a9 --- /dev/null +++ b/docs/notes/heap-effect-summaries.md @@ -0,0 +1,113 @@ +# H0 — heap-effect summaries (inert) + +Status: **landed inert**. A second summary domain beside the Method Ownership +Summary, produced and solved end to end, and read by nothing that decides a +verdict or a refusal. Follows +[`protocol-harmless-call-summary-gap.md`](protocol-harmless-call-summary-gap.md), +which found that the MOS / P-037 transfer lattice cannot prove a call harmless. +Base: `main` = `a27a8277b40309aa56b945e73dd305ddb90e72b4`. + +## 1. The domain + +Per source-visible method: + +| field | lattice | meaning | +|---|---|---| +| `params[i].effect` | `plain < borrow < borrow_mut < may_escape < unknown` | what the method may do to the object graph of argument *i* | +| `receiver` | same, or `null` for a static method | the same for `this` | +| `writes.instance` / `.static` / `.indirect` | `none < may < unknown` | writes to memory reached from **neither** a parameter **nor** the receiver: an object field, a static field, an array element / indirect storage | +| `returns` | `[]` · some of `param:`, `receiver`, `heap` · `["unknown"]` | what the returned value may alias (`[]` also for an inert return) | +| `unresolved` | list | the method's own reasons for an Unknown (evidence only) | + +- `plain` means the argument's graph is not touched. Every inert value (an unmanaged type or `string`) is `plain`, even under Unknown. +- `borrow` means the argument is read through. +- `borrow_mut` means it may be written through: a field, an element, a `ref`/`out` location. +- `may_escape` means it may be stored where it outlives the call. After that anybody may write it, so on this chain `may_escape` subsumes `borrow_mut`. Both disqualify "harmless". +- A write through a parameter is that parameter's `borrow_mut`, never a `writes` entry. So a callee's effect maps exactly onto the caller's arguments. + +**Unknown absorbs** wherever silence would read as clean: +- **Unmodelled construct in the body:** Unknown on every non-inert parameter, the receiver, every write kind and the return. +- **Call with no summary** (virtual, `extern`, delegate, or a direct call with no record): Unknown on every argument and the receiver, and every write kind Unknown. +- **Value returned by such a call:** an Unknown root. A write through it is an Unknown write. + +## 2. Where the source facts are born + +`ownsharp-extract … --heap-effects FILE`. The producer is `frontend/roslyn/OwnSharp.Extractor/HeapEffectFacts.cs`, wired at the very end of `Program.cs`, after the facts JSON is final. It writes a **separate file**. The facts document is never touched, and no OwnIR field, op or version moves. + +It is fact extraction, not an analysis. One `IOperation` body at a time, the walk records the following, in tokens `param:`, `receiver`, `heap`, `local:`, `call:`: +- each value's sources; +- derefs and writes (kind + target); +- stores (values put somewhere that outlives the call); +- returns; +- locals (flow-insensitive); +- calls: callee key, dispatch `direct|virtual|extern|delegate`, receiver, and per-parameter arguments; +- `unknown` reasons. + +The walk is **default-deny**: any construct it does not model is an `unknown` reason. It never solves, never reads a callee body from a call site, never says "harmless". + +The vocabulary is version 1 (`heap_effects_version`). The record schema is the one validated by `ownlang/heap_effects.py::load`. + +Deliberate conservatisms, all in the fail-closed direction: +- **New objects:** `new T(...)` reads as `heap` (its contents are not tracked). A write to a fresh object is a heap write. +- **Unknown constructs:** lambdas, local functions, `foreach` over a non-array, `using`, `lock`, `await`, iterators, object/collection initializers, events, ref locals, patterns that run getters, `dynamic`, pointers. +- **Static initialization:** touching a type with an explicit static constructor or a non-constant static initializer is Unknown, inside the type too: a `beforefieldinit` initializer may run lazily at the first static-field access. +- **External statics:** a static field of a type with no source is Unknown. +- **Constructors:** one whose type has non-constant instance initializers is Unknown. An implicit constructor is accepted only for a value type or a direct `object` subclass with no instance initializers. +- **The one exemption:** `object()` is empty. + +## 3. Where the fixpoint is solved + +- **Python reference:** `ownlang/heap_effects.py`. Run it with `python -m ownlang.heap_effects FILE`. It is deliberately **not** a `python -m ownlang` subcommand, so the public CLI surface (and its Rust mirror) is unchanged. +- **Rust port:** `rust/crates/own-bridge/src/heap_effects.rs`, public as `own_bridge::dump_heap_effects(text)`. It sits beside `mos.rs` and shares `dump.rs`'s Python-exact JSON emitter. + +The solve is a least fixpoint over the call graph's SCC condensation: Tarjan bottom-up, then iteration inside each component from ⊥. Every transfer function is monotone over finite lattices, so the result is independent of iteration and input order. + +## 4. Parity + +- **Byte-exact goldens:** `tests/fixtures/heap_effects/` holds 10 cases and 16 rejections. `.summaries.json` is the exact stdout of the reference; `.rejected.txt` is the exact rejection text, and the validation order is part of the contract. +- **Python side:** `tests/test_heap_effects_fixtures.py` checks the goldens, input-order independence (`methods[]` reversed), the ledger, the meaning of 30 pinned summaries, and that no `ownlang` module imports `heap_effects`. +- **Rust side:** `own-bridge/tests/heap_effects.rs` replays every golden and every rejection text, re-derives the ledger, and checks determinism and order independence, with zero Python. +- **Differential fuzz (development):** 4000 random sidecars, 1318 of them corrupted into rejections, gave identical bytes and messages from both engines. Three planted solver mutants were each caught by both the goldens and the fuzz. +- **Parity domain:** RFC 8259 JSON whose integers fit 64 bits. The reference reader refuses `NaN`/`Infinity`, overflowing numbers and lone surrogates, as serde does. + +## 5. Kill fixtures (`frontend/roslyn/heap-effects-samples/HeapEffects.cs`) + +| method | params | receiver | writes | returns | +|---|---|---|---|---| +| `Twice(int)` | plain | — | none | [] — a harmless candidate | +| `Mutates(Order)` | **borrow_mut** | — | none | [] | +| `Escapes(Order)` | **may_escape** | — | static: **may** | [] | +| `TouchesGlobalState()` | — | — | static: **may** | [] | +| `Logs()` (`Console.WriteLine`) | — | — | **unknown** ×3 | [] (unresolved: `System.Console.WriteLine(string?) (extern)`) | +| `A(x) => B(x)`, `B` writes `x.State` | **borrow_mut** (only via B's summary) | — | none | [] | +| `EscapeOuter → EscapeInner` | **may_escape** | — | static: may | [] | +| `Even ⇄ Odd` (pure SCC) | plain | — | none | [] | +| `Ping ⇄ Pong` (Pong writes) | borrow_mut, plain (both) | — | none | [] | +| `Tick ⇄ Tock` (Tock logs) | unknown, plain (both) | — | unknown ×3 | [] | +| `CallsLogs() => Logs()` | — | — | unknown ×3 | [] | + +Receiver mapping, local aliases, a reassigned parameter, array elements, return aliases through calls, static initialization, lambdas, virtual dispatch and a captured primary-constructor parameter are pinned beside them. + +## 6. Inertness + +**Gate:** `scripts/heap_effects_gate.py`, which needs `dotnet` and runs in CI's `state-protocols` job. +1. It re-extracts the samples and requires the committed sidecar. +2. It runs the extractor with and without `--heap-effects` over every protocol case, every protocol refusal, the samples, and `examples/`: 38 runs. Exit code, stderr and facts bytes must be identical. + +**Against `main` (measured once for this change):** the `main` extractor (`a27a827`) and this one, without the flag, were compared over 208 C# inputs (`corpus/`, `examples/`, the protocol samples, fixtures) plus four directory scans in three flag modes (`--flow-locals`, `--fix-candidates`, none). All 220 runs gave identical facts bytes, exit codes and stderr, including the 2 refusals. + +**The core:** no verdict-path module changed. The only edit to existing Rust code is the visibility of `dump.rs::emit` (private → `pub(crate)`). The existing suites (`tests/run_tests.py`, `cargo test`) pass unchanged. The coordinate census (`docs/generated/p022-coord-census.md`) is regenerated only because it now also counts the new fixture family's `line` slots. + +## 7. What H1 still needs before `ProtocolLowering` can ask `Harmless(callee, region-resource)` + +1. **The decision point: an owner ruling.** `ProtocolLowering` refuses in the extractor, before any fact exists, while the summary is solved in the core. Moving the decision core-side needs a must-understand construct for "a call inside this region whose effect must be proven", which is an OwnIR v2 op (IR3/IR4), plus the sidecar travelling with the facts it describes. Solving in the extractor is not an option: frontends emit facts only (IR6). +2. **The predicate**, over a solved summary of a `direct` callee: + - all parameters and the receiver at most `borrow`; + - every `writes` kind `none`; + - `returns` either `[]` or not aliasing a parameter, the receiver or the heap; + - no Unknown anywhere; + - entity-typed arguments still lowered as a `use` (a verdict), as today. + + "Region-resource" precision (allowing a write to state that provably is not the entity's type family) needs typed write targets, which H0 does not carry. Without them H1 is the strict predicate, which is sound. +3. **External calls.** `logger.LogInformation(...)` is `extern`, so it is Unknown, so the motivating example stays refused under H1. Admitting it needs curated or annotated summaries (P-036's `source: bcl | annotation`), a separate slice with its own trust argument. +4. **Precision where it matters**, only if H1's first consumer runs into it: fresh-object tracking, local functions and lambdas, `foreach` via a known enumerator, sealed-receiver devirtualization. diff --git a/docs/notes/protocol-harmless-call-summary-gap.md b/docs/notes/protocol-harmless-call-summary-gap.md index a81bcddc..13867038 100644 --- a/docs/notes/protocol-harmless-call-summary-gap.md +++ b/docs/notes/protocol-harmless-call-summary-gap.md @@ -3,6 +3,9 @@ Status: **BLOCKED BY SUMMARY INFRASTRUCTURE** — a kill-first finding, no code change. Measured at `main` = `a27a8277b40309aa56b945e73dd305ddb90e72b4`. +**Follow-up:** slice H0 (§4) landed inert — see +[`heap-effect-summaries.md`](heap-effect-summaries.md). H1 is still open. + Question: can `ProtocolLowering` let a *provably harmless* call stand inside a state-protocol region by consuming the existing P-036/P-037 summary layer, without weakening the fail-closed default? diff --git a/frontend/roslyn/OwnSharp.Extractor/HeapEffectFacts.cs b/frontend/roslyn/OwnSharp.Extractor/HeapEffectFacts.cs new file mode 100644 index 00000000..b7f6beed --- /dev/null +++ b/frontend/roslyn/OwnSharp.Extractor/HeapEffectFacts.cs @@ -0,0 +1,953 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +// Heap-effect source facts (H0, docs/notes/heap-effect-summaries.md): the LOCAL evidence the +// core's heap-effect summary solver (ownlang/heap_effects.py, own-bridge heap_effects.rs) +// composes into per-method summaries. Written ONLY under `--heap-effects FILE`, to that file: +// the OwnIR facts document is never touched, with or without the flag. +// +// This is FACT EXTRACTION, not an analysis. It reads one method body at a time and records, +// in a small token vocabulary, what the body itself does: +// +// a value's SOURCES param: | receiver | heap | local: | call: +// (a value of an inert type — unmanaged or string — carries nothing) +// derefs sources read THROUGH (a field, an element, a property) +// writes {kind: instance|static|indirect, target: sources of the written object} +// stores sources of values put somewhere that outlives the call +// returns sources of the returned value +// calls callee key, dispatch, receiver and per-parameter argument sources +// locals per local, the sources ever assigned to it (flow-insensitive) +// unknown reasons the body has a construct this vocabulary does not model +// +// It never decides anything: no summary is solved here, no call is called harmless, no callee +// body is read from a call site. Every construct the walk does not model is recorded as an +// `unknown` reason, and the solver reads a method with one as Unknown everywhere — the +// vocabulary is default-deny, so a missing case can only lose precision, never invent "clean". +internal static class HeapEffectFacts +{ + internal const int Version = 1; + + // One spelling for a method's identity, on both sides of a call edge. + private static readonly SymbolDisplayFormat KeyFormat = SymbolDisplayFormat.CSharpErrorMessageFormat; + + internal static string Key(IMethodSymbol method) => + (method.ReducedFrom ?? method).OriginalDefinition.ToDisplayString(KeyFormat); + + public static Dictionary Extract(Compilation compilation, + IReadOnlyList<(string file, SyntaxTree tree)> parsed) + { + var methods = new SortedDictionary>(StringComparer.Ordinal); + foreach (var (file, tree) in parsed) + { + var model = compilation.GetSemanticModel(tree); + foreach (var node in tree.GetRoot().DescendantNodes()) + { + IMethodSymbol? symbol = null; + IOperation? body = null; + switch (node) + { + case BaseMethodDeclarationSyntax m when m.Body is not null || m.ExpressionBody is not null: + symbol = model.GetDeclaredSymbol(m) as IMethodSymbol; + body = model.GetOperation(m); + break; + case AccessorDeclarationSyntax a when a.Body is not null || a.ExpressionBody is not null: + symbol = model.GetDeclaredSymbol(a) as IMethodSymbol; + body = model.GetOperation(a); + break; + case PropertyDeclarationSyntax { ExpressionBody: { } arrow } p: + symbol = (model.GetDeclaredSymbol(p) as IPropertySymbol)?.GetMethod; + body = model.GetOperation(arrow); + break; + case IndexerDeclarationSyntax { ExpressionBody: { } arrow } ix: + symbol = (model.GetDeclaredSymbol(ix) as IPropertySymbol)?.GetMethod; + body = model.GetOperation(arrow); + break; + } + if (symbol is null || body is null) + continue; + var key = Key(symbol); + if (methods.ContainsKey(key)) + continue; // one record per method (a file passed twice is still one method) + methods[key] = new Walk(compilation, symbol, file, node).Run(body); + } + } + return new Dictionary + { + ["heap_effects_version"] = Version, + ["methods"] = methods.Values.ToList(), + }; + } + + /// A value of this type carries no reference: copying it shares nothing with the caller. + internal static bool Inert(ITypeSymbol? type) => + type is not null + && type.TypeKind is not (TypeKind.Pointer or TypeKind.FunctionPointer or TypeKind.Dynamic) + && (type.SpecialType == SpecialType.System_String || type.IsUnmanagedType); + + /// Formatting a value of this type runs no user code (`ToString` of a primitive or enum). + private static bool FormatSafe(ITypeSymbol? type) => + type is not null && (type.TypeKind == TypeKind.Enum || type.SpecialType is + SpecialType.System_Boolean or SpecialType.System_Char or SpecialType.System_String + or SpecialType.System_SByte or SpecialType.System_Byte + or SpecialType.System_Int16 or SpecialType.System_UInt16 + or SpecialType.System_Int32 or SpecialType.System_UInt32 + or SpecialType.System_Int64 or SpecialType.System_UInt64 + or SpecialType.System_Single or SpecialType.System_Double + or SpecialType.System_Decimal); + + private sealed class Walk + { + private readonly Compilation _compilation; + private readonly IMethodSymbol _method; + private readonly string _file; + private readonly SyntaxNode _decl; + private readonly bool _returnInert; + + private readonly Dictionary _localIds = new(SymbolEqualityComparer.Default); + private readonly List<(string Name, SortedSet Sources)> _locals = new(); + // value parameters that are assigned somewhere: read through a local slot seeded with + // `param:`, so a reassigned parameter's later value is not mistaken for the argument + private readonly Dictionary _reassigned = new(SymbolEqualityComparer.Default); + private readonly SortedSet _derefs = new(StringComparer.Ordinal); + private readonly SortedSet _stores = new(StringComparer.Ordinal); + private readonly SortedSet _returns = new(StringComparer.Ordinal); + private readonly SortedDictionary Target)> _writes = new(StringComparer.Ordinal); + private readonly List> _calls = new(); + private readonly SortedSet _unknown = new(StringComparer.Ordinal); + private readonly Stack> _conditional = new(); + + public Walk(Compilation compilation, IMethodSymbol method, string file, SyntaxNode decl) + { + _compilation = compilation; + _method = method; + _file = file; + _decl = decl; + _returnInert = method.ReturnsVoid || Inert(method.ReturnType); + } + + private static SortedSet None() => new(StringComparer.Ordinal); + + private static SortedSet One(string token) => new(StringComparer.Ordinal) { token }; + + private void Unknown(string reason) => _unknown.Add(reason); + + public Dictionary Run(IOperation body) + { + if (_method.IsAsync) + Unknown("async method"); + if (_method.ReturnsByRef || _method.ReturnsByRefReadonly) + Unknown("ref return"); + if (_method.MethodKind == MethodKind.Constructor && HasInstanceInitializers(_method.ContainingType)) + Unknown($"instance initializers of {_method.ContainingType.ToDisplayString()}"); + foreach (var p in _method.Parameters) + if (!Inert(p.Type) || p.RefKind is RefKind.Ref or RefKind.Out) + if (IsReassigned(body, p) && p.RefKind is RefKind.None or RefKind.In) + { + var slot = NewLocal(p.Name); + _locals[slot].Sources.Add($"param:{p.Ordinal}"); + _reassigned[p] = slot; + } + Exec(body); + + var line = _decl.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + return new Dictionary + { + ["key"] = Key(_method), + ["file"] = _file, + ["line"] = line, + ["receiver"] = !_method.IsStatic, + ["return_inert"] = _returnInert, + ["params"] = _method.Parameters.Select(p => new Dictionary + { + ["index"] = p.Ordinal, + ["name"] = p.Name, + ["inert"] = Inert(p.Type) && p.RefKind is RefKind.None or RefKind.In, + }).ToList(), + ["locals"] = _locals.Select((l, i) => new Dictionary + { + ["id"] = i, ["name"] = l.Name, ["sources"] = l.Sources.ToList(), + }).ToList(), + ["derefs"] = _derefs.ToList(), + ["writes"] = _writes.Values.Select(w => new Dictionary + { + ["kind"] = w.Kind, ["target"] = w.Target.ToList(), + }).ToList(), + ["stores"] = _stores.ToList(), + ["returns"] = _returns.ToList(), + ["calls"] = _calls, + ["unknown"] = _unknown.ToList(), + }; + } + + /// A parameter of the method being read (not one a member captured from elsewhere). + private bool Own(IParameterSymbol p) => + SymbolEqualityComparer.Default.Equals(p.ContainingSymbol, _method); + + private static bool IsReassigned(IOperation body, IParameterSymbol p) => + body.Descendants().Any(op => op switch + { + IAssignmentOperation a => Refers(a.Target, p), + IIncrementOrDecrementOperation i => Refers(i.Target, p), + IArgumentOperation { Parameter.RefKind: RefKind.Ref or RefKind.Out } arg => Refers(arg.Value, p), + _ => false, + }); + + private static bool Refers(IOperation target, IParameterSymbol p) => + target is IParameterReferenceOperation r && SymbolEqualityComparer.Default.Equals(r.Parameter, p); + + private int NewLocal(string name) + { + _locals.Add((name, None())); + return _locals.Count - 1; + } + + private int LocalId(ILocalSymbol local) + { + if (!_localIds.TryGetValue(local, out var id)) + { + id = NewLocal(local.Name); + _localIds[local] = id; + } + return id; + } + + private void Write(string kind, SortedSet target) + { + var key = kind + "|" + string.Join(",", target); + if (!_writes.ContainsKey(key)) + _writes[key] = (kind, new SortedSet(target, StringComparer.Ordinal)); + } + + // ---- static initialization: a hidden effect of naming a type ---------------------- + + private void StaticAccess(INamedTypeSymbol type, string what) + { + if (type.DeclaringSyntaxReferences.IsEmpty) + { + Unknown($"{what} of external type {type.ToDisplayString()}"); + return; + } + if (HasStaticInitialization(type)) + Unknown($"static initialization of {type.ToDisplayString()}"); + } + + private bool HasStaticInitialization(INamedTypeSymbol type) + { + if (type.StaticConstructors.Any(c => !c.IsImplicitlyDeclared)) + return true; + return HasInitializers(type, isStatic: true); + } + + private bool HasInstanceInitializers(INamedTypeSymbol type) => HasInitializers(type, isStatic: false); + + /// A field or property initializer that runs code: anything but a compile-time constant + /// (a `null` literal is one). + private bool HasInitializers(INamedTypeSymbol type, bool isStatic) + { + foreach (var member in type.GetMembers()) + { + if (member.IsStatic != isStatic || member is not (IFieldSymbol or IPropertySymbol or IEventSymbol)) + continue; + foreach (var reference in member.DeclaringSyntaxReferences) + { + var syntax = reference.GetSyntax(); + var value = syntax switch + { + VariableDeclaratorSyntax v => v.Initializer?.Value, + PropertyDeclarationSyntax p => p.Initializer?.Value, + _ => null, + }; + if (value is null) + continue; + var model = _compilation.GetSemanticModel(syntax.SyntaxTree); + if (!model.GetConstantValue(value).HasValue) + return true; + } + } + return false; + } + + // ---- statements ------------------------------------------------------------------ + + private void Exec(IOperation? op) + { + switch (op) + { + case null: + case IEmptyOperation: + case IBranchOperation: + return; + case IMethodBodyOperation body: + Exec((IOperation?)body.BlockBody ?? body.ExpressionBody); + return; + case IConstructorBodyOperation ctor: + Exec(ctor.Initializer); + Exec((IOperation?)ctor.BlockBody ?? ctor.ExpressionBody); + return; + case IBlockOperation block: + foreach (var statement in block.Operations) + Exec(statement); + return; + case ILabeledOperation labeled: + Exec(labeled.Operation); + return; + case IExpressionStatementOperation statement: + Eval(statement.Operation); + return; + case IVariableDeclarationGroupOperation group: + foreach (var declaration in group.Declarations) + Declare(declaration); + return; + case IReturnOperation ret when ret.Kind == OperationKind.Return: + var value = Eval(ret.ReturnedValue); + if (!_returnInert) + _returns.UnionWith(value); + return; + case IConditionalOperation cond when cond.Type is null: + Eval(cond.Condition); + Exec(cond.WhenTrue); + Exec(cond.WhenFalse); + return; + case IWhileLoopOperation loop: + Eval(loop.Condition); + Exec(loop.Body); + Eval(loop.IgnoredCondition); + return; + case IForLoopOperation loop: + foreach (var before in loop.Before) + Exec(before); + Eval(loop.Condition); + Exec(loop.Body); + foreach (var after in loop.AtLoopBottom) + Exec(after); + return; + case IForEachLoopOperation loop: + ForEach(loop); + return; + case IThrowOperation thrown: + Eval(thrown); + return; + case ITryOperation tryOp: + Exec(tryOp.Body); + foreach (var handler in tryOp.Catches) + { + if (handler.ExceptionDeclarationOrExpression is IVariableDeclaratorOperation caught) + _locals[LocalId(caught.Symbol)].Sources.Add("heap"); + else if (handler.ExceptionDeclarationOrExpression is not null) + Unknown("catch clause with an expression"); + Eval(handler.Filter); + Exec(handler.Handler); + } + Exec(tryOp.Finally); + return; + case ISwitchOperation sw: + var subject = Eval(sw.Value); + foreach (var section in sw.Cases) + { + foreach (var clause in section.Clauses) + switch (clause) + { + case ISingleValueCaseClauseOperation single: + Eval(single.Value); + break; + case IDefaultCaseClauseOperation: + break; + case IPatternCaseClauseOperation pattern: + Pattern(pattern.Pattern, subject); + Eval(pattern.Guard); + break; + default: + Unknown($"case clause {clause.CaseKind}"); + break; + } + foreach (var statement in section.Body) + Exec(statement); + } + return; + default: + Unknown($"statement {op.Kind}"); + return; + } + } + + private void Declare(IVariableDeclarationOperation declaration) + { + foreach (var dimension in declaration.IgnoredDimensions) + Eval(dimension); + foreach (var declarator in declaration.Declarators) + { + if (declarator.Symbol.IsRef) + { + Unknown("ref local"); + continue; + } + var init = declarator.Initializer ?? declaration.Initializer; + var value = Eval(init?.Value); + _locals[LocalId(declarator.Symbol)].Sources.UnionWith(value); + } + } + + private void ForEach(IForEachLoopOperation loop) + { + var collection = loop.Collection is IConversionOperation { OperatorMethod: null } conversion + ? conversion.Operand + : loop.Collection; + if (collection.Type is not IArrayTypeSymbol array) + { + Unknown("foreach over a non-array (an enumerator runs code)"); + return; + } + var items = Eval(collection); + _derefs.UnionWith(items); + if (loop.LoopControlVariable is IVariableDeclaratorOperation variable && !variable.Symbol.IsRef) + { + if (!Inert(array.ElementType)) + _locals[LocalId(variable.Symbol)].Sources.UnionWith(items); + } + else + { + Unknown("foreach loop variable"); + } + Exec(loop.Body); + } + + // ---- patterns -------------------------------------------------------------------- + + private void Pattern(IPatternOperation pattern, SortedSet subject) + { + switch (pattern) + { + case IConstantPatternOperation constant: + Eval(constant.Value); + return; + case IRelationalPatternOperation relational: + Eval(relational.Value); + return; + case ITypePatternOperation: + case IDiscardPatternOperation: + return; + case IDeclarationPatternOperation declaration: + if (declaration.DeclaredSymbol is ILocalSymbol local && !local.IsRef) + _locals[LocalId(local)].Sources.UnionWith(Inert(local.Type) ? None() : subject); + else if (declaration.DeclaredSymbol is not null) + Unknown("pattern declaration"); + return; + case INegatedPatternOperation negated: + Pattern(negated.Pattern, subject); + return; + case IBinaryPatternOperation binary: + Pattern(binary.LeftPattern, subject); + Pattern(binary.RightPattern, subject); + return; + default: + // property / positional / list patterns run getters and Deconstruct + Unknown($"pattern {pattern.Kind}"); + return; + } + } + + // ---- expressions: the sources of a value ----------------------------------------- + + private SortedSet Eval(IOperation? op) + { + if (op is null) + return None(); + if (op.ConstantValue.HasValue) + return None(); // a constant runs nothing (`nameof` included) + if (op.Type is { TypeKind: TypeKind.Dynamic }) + { + Unknown("dynamic"); + return None(); + } + var value = Value(op); + return Inert(op.Type) ? None() : value; + } + + private SortedSet Value(IOperation op) + { + switch (op) + { + case ILiteralOperation: + case IDefaultValueOperation: + case ITypeOfOperation: + case ISizeOfOperation: + case IDiscardOperation: + return None(); + case IParameterReferenceOperation p when !Own(p.Parameter): + // a primary-constructor parameter captured by a member: hidden state + Unknown("captured primary-constructor parameter"); + return None(); + case IParameterReferenceOperation p: + if (_reassigned.TryGetValue(p.Parameter, out var slot)) + return One($"local:{slot}"); + return One($"param:{p.Parameter.Ordinal}"); + case IInstanceReferenceOperation { ReferenceKind: InstanceReferenceKind.ContainingTypeInstance }: + return One("receiver"); + case IInstanceReferenceOperation: + Unknown("implicit receiver (an object or collection initializer)"); + return None(); + case ILocalReferenceOperation l: + if (l.Local.IsRef) + { + Unknown("ref local"); + return None(); + } + return One($"local:{LocalId(l.Local)}"); + case IFieldReferenceOperation f: + return Field(f); + case IPropertyReferenceOperation prop: + return PropertyRead(prop); + case IArrayElementReferenceOperation element: + { + var array = Eval(element.ArrayReference); + foreach (var index in element.Indices) + Eval(index); + _derefs.UnionWith(array); + return array; + } + case IInvocationOperation inv: + return Invocation(inv); + case IObjectCreationOperation creation: + return Creation(creation); + case IArrayCreationOperation creation: + foreach (var size in creation.DimensionSizes) + Eval(size); + if (creation.Initializer is not null) + foreach (var item in Flatten(creation.Initializer)) + _stores.UnionWith(Eval(item)); + return One("heap"); + case IConversionOperation conversion: + if (conversion.OperatorMethod is not null) + return Call(conversion.OperatorMethod, null, Positional(conversion.OperatorMethod, conversion.Operand), conversion, nonVirtual: true); + if (conversion.Operand is IDelegateCreationOperation or IAnonymousFunctionOperation or IMethodReferenceOperation) + { + Unknown("delegate creation"); + return None(); + } + return Eval(conversion.Operand); + case IBinaryOperation binary: + if (binary.OperatorMethod is not null) + return Call(binary.OperatorMethod, null, Positional(binary.OperatorMethod, binary.LeftOperand, binary.RightOperand), binary, nonVirtual: true); + if (binary.Type?.SpecialType == SpecialType.System_String + && !(FormatSafe(binary.LeftOperand.Type) && FormatSafe(binary.RightOperand.Type))) + { + Unknown("string concatenation of a non-primitive value (its ToString runs)"); + return None(); + } + var left = Eval(binary.LeftOperand); + left.UnionWith(Eval(binary.RightOperand)); + return left; + case IUnaryOperation unary: + if (unary.OperatorMethod is not null) + return Call(unary.OperatorMethod, null, Positional(unary.OperatorMethod, unary.Operand), unary, nonVirtual: true); + return Eval(unary.Operand); + case IIncrementOrDecrementOperation step: + if (step.OperatorMethod is not null) + Call(step.OperatorMethod, null, Positional(step.OperatorMethod, step.Target), step, nonVirtual: true); + Assign(step.Target, Eval(step.Target)); + return None(); + case ISimpleAssignmentOperation assignment: + { + if (assignment.IsRef) + { + Unknown("ref assignment"); + return None(); + } + var value = Eval(assignment.Value); + Assign(assignment.Target, value); + return value; + } + case ICompoundAssignmentOperation compound: + { + var current = Eval(compound.Target); + var value = Eval(compound.Value); + if (compound.OperatorMethod is not null) + current.UnionWith(Call(compound.OperatorMethod, null, Positional(compound.OperatorMethod, compound.Target, compound.Value), compound, nonVirtual: true)); + else if (compound.Type?.SpecialType == SpecialType.System_String + && !(FormatSafe(compound.Target.Type) && FormatSafe(compound.Value.Type))) + Unknown("string concatenation of a non-primitive value (its ToString runs)"); + current.UnionWith(value); + Assign(compound.Target, current); + return current; + } + case ICoalesceAssignmentOperation coalesce: + { + var current = Eval(coalesce.Target); + current.UnionWith(Eval(coalesce.Value)); + Assign(coalesce.Target, current); + return current; + } + case ICoalesceOperation coalesce: + { + var value = Eval(coalesce.Value); + value.UnionWith(Eval(coalesce.WhenNull)); + return value; + } + case IConditionalOperation cond: + { + if (cond.IsRef) + { + Unknown("ref conditional"); + return None(); + } + Eval(cond.Condition); + var value = Eval(cond.WhenTrue); + value.UnionWith(Eval(cond.WhenFalse)); + return value; + } + case IConditionalAccessOperation access: + { + var target = Eval(access.Operation); + _conditional.Push(target); + var value = Eval(access.WhenNotNull); + _conditional.Pop(); + return value; + } + case IConditionalAccessInstanceOperation: + return _conditional.Count > 0 ? new SortedSet(_conditional.Peek(), StringComparer.Ordinal) : None(); + case ITupleOperation tuple: + { + var value = None(); + foreach (var element in tuple.Elements) + value.UnionWith(Eval(element)); + return value; + } + case IInterpolatedStringOperation interpolated: + foreach (var part in interpolated.Parts) + switch (part) + { + case IInterpolatedStringTextOperation: + break; + case IInterpolationOperation hole when FormatSafe(hole.Expression.Type): + Eval(hole.Expression); + Eval(hole.Alignment); + Eval(hole.FormatString); + break; + default: + Unknown("interpolation of a non-primitive value (its ToString runs)"); + break; + } + return None(); + case IIsTypeOperation isType: + Eval(isType.ValueOperand); + return None(); + case IIsPatternOperation isPattern: + Pattern(isPattern.Pattern, Eval(isPattern.Value)); + return None(); + case ISwitchExpressionOperation sw: + { + var subject = Eval(sw.Value); + var value = None(); + foreach (var arm in sw.Arms) + { + Pattern(arm.Pattern, subject); + Eval(arm.Guard); + value.UnionWith(Eval(arm.Value)); + } + return value; + } + case IThrowOperation thrown: + _stores.UnionWith(Eval(thrown.Exception)); + return None(); + case IDeclarationExpressionOperation declaration: + return Eval(declaration.Expression); + default: + Unknown($"expression {op.Kind}"); + return None(); + } + } + + private static IEnumerable Flatten(IArrayInitializerOperation initializer) + { + foreach (var value in initializer.ElementValues) + if (value is IArrayInitializerOperation nested) + foreach (var inner in Flatten(nested)) + yield return inner; + else + yield return value; + } + + private SortedSet Field(IFieldReferenceOperation f) + { + if (f.Field.IsStatic) + { + StaticAccess(f.Field.ContainingType, "static field"); + return One("heap"); + } + var instance = Eval(f.Instance); + _derefs.UnionWith(instance); + return instance; + } + + /// An auto-property is its backing field: no accessor body runs. One that may be + /// overridden is not — the override's accessor may have a body. + private static bool FieldLike(IPropertySymbol property) + { + if (property.IsAbstract || property.IsExtern || property.IsIndexer) + return false; + if ((property.IsVirtual || property.IsOverride) && !property.IsSealed && !property.ContainingType.IsSealed) + return false; + if (property.DeclaringSyntaxReferences.IsEmpty) + return false; + foreach (var reference in property.DeclaringSyntaxReferences) + switch (reference.GetSyntax()) + { + case PropertyDeclarationSyntax { ExpressionBody: null, AccessorList: { } accessors } + when accessors.Accessors.All(a => a.Body is null && a.ExpressionBody is null): + continue; + case ParameterSyntax: // a record's positional property + continue; + default: + return false; + } + return true; + } + + private SortedSet PropertyRead(IPropertyReferenceOperation prop) + { + var property = prop.Property; + if (FieldLike(property)) + { + if (property.IsStatic) + { + StaticAccess(property.ContainingType, "static property"); + return One("heap"); + } + var instance = Eval(prop.Instance); + _derefs.UnionWith(instance); + return instance; + } + // the length of an array is the `ldlen` instruction, not a call + if (property.ContainingType.SpecialType == SpecialType.System_Array + && property.Name is "Length" or "LongLength") + { + _derefs.UnionWith(Eval(prop.Instance)); + return None(); + } + if (property.GetMethod is null) + { + Unknown($"property {property.Name} without a getter"); + return None(); + } + return Call(property.GetMethod, prop.Instance, ByOrdinal(property.GetMethod, prop.Arguments), prop, + nonVirtual: IsBaseAccess(prop.Instance)); + } + + private void Assign(IOperation target, SortedSet value) + { + switch (target) + { + case ILocalReferenceOperation l when !l.Local.IsRef: + _locals[LocalId(l.Local)].Sources.UnionWith(Inert(l.Local.Type) ? None() : value); + return; + case IParameterReferenceOperation p when !Own(p.Parameter): + Unknown("captured primary-constructor parameter"); + return; + case IParameterReferenceOperation p when _reassigned.TryGetValue(p.Parameter, out var slot): + _locals[slot].Sources.UnionWith(value); + return; + case IParameterReferenceOperation p when p.Parameter.RefKind is RefKind.Ref or RefKind.Out: + // the caller's storage: written, and what goes in leaves this method + Write("indirect", One($"param:{p.Parameter.Ordinal}")); + _stores.UnionWith(value); + return; + case IParameterReferenceOperation: + return; // an inert value parameter: a local copy + case IFieldReferenceOperation f when f.Field.IsStatic: + StaticAccess(f.Field.ContainingType, "static field"); + Write("static", None()); + _stores.UnionWith(value); + return; + case IFieldReferenceOperation f: + Write("instance", Eval(f.Instance)); + _stores.UnionWith(value); + return; + case IPropertyReferenceOperation prop when FieldLike(prop.Property) || prop.Property.SetMethod is null: + if (!FieldLike(prop.Property) && !InOwnConstructor(prop.Property)) + { + Unknown($"assignment to property {prop.Property.Name} without a setter"); + return; + } + if (prop.Property.IsStatic) + { + StaticAccess(prop.Property.ContainingType, "static property"); + Write("static", None()); + } + else + { + Write("instance", Eval(prop.Instance)); + } + _stores.UnionWith(value); + return; + case IPropertyReferenceOperation prop: + { + var setter = prop.Property.SetMethod!; + var args = ByOrdinal(setter, prop.Arguments); + args[setter.Parameters.Length - 1].UnionWith(value); + Call(setter, prop.Instance, args, prop, nonVirtual: IsBaseAccess(prop.Instance)); + return; + } + case IArrayElementReferenceOperation element: + { + var array = Eval(element.ArrayReference); + foreach (var index in element.Indices) + Eval(index); + Write("indirect", array); + _stores.UnionWith(value); + return; + } + case IDiscardOperation: + return; + case IDeclarationExpressionOperation declaration: + Assign(declaration.Expression, value); + return; + default: + Unknown($"assignment to {target.Kind}"); + return; + } + } + + /// A get-only auto-property assigned in its own type's constructor writes its backing field. + private bool InOwnConstructor(IPropertySymbol property) => + _method.MethodKind is MethodKind.Constructor or MethodKind.StaticConstructor + && SymbolEqualityComparer.Default.Equals(property.ContainingType, _method.ContainingType); + + private static bool IsBaseAccess(IOperation? instance) => + instance?.Syntax is BaseExpressionSyntax; + + // ---- calls ----------------------------------------------------------------------- + + private SortedSet[] Slots(IMethodSymbol method) => + method.Parameters.Select(_ => None()).ToArray(); + + private SortedSet[] Positional(IMethodSymbol method, params IOperation[] operands) + { + var args = Slots(method); + for (var i = 0; i < operands.Length && i < args.Length; i++) + args[i].UnionWith(Eval(operands[i])); + return args; + } + + private SortedSet[] ByOrdinal(IMethodSymbol method, ImmutableArray arguments) + { + var args = Slots(method); + foreach (var argument in arguments) + { + var ordinal = argument.Parameter?.Ordinal ?? -1; + var value = Eval(argument.Value); + if (argument.Parameter is { RefKind: RefKind.Ref or RefKind.Out }) + ByRef(argument.Value); + if (ordinal < 0 || ordinal >= args.Length) + { + Unknown("an argument with no parameter"); + continue; + } + if (argument.ArgumentKind == ArgumentKind.ParamArray) + _stores.UnionWith(value); // packed into a fresh array the callee keeps + args[ordinal].UnionWith(value); + } + return args; + } + + /// A `ref` / `out` argument: the callee may write the location. What it writes comes + /// from the callee's own parameters (which it then stores) or from the heap. + private void ByRef(IOperation location) + { + switch (location) + { + case IDeclarationExpressionOperation declaration: + ByRef(declaration.Expression); + return; + case IDiscardOperation: + return; + default: + Assign(location, One("heap")); + return; + } + } + + private SortedSet Invocation(IInvocationOperation inv) + { + var method = inv.TargetMethod; + if (method.MethodKind == MethodKind.LocalFunction) + { + Unknown("local function call"); + return None(); + } + var nonVirtual = !inv.IsVirtual || IsBaseAccess(inv.Instance); + return Call(method, inv.Instance, ByOrdinal(method, inv.Arguments), inv, nonVirtual); + } + + private SortedSet Creation(IObjectCreationOperation creation) + { + if (creation.Initializer is not null) + { + Unknown("object or collection initializer"); + return None(); + } + var ctor = creation.Constructor; + if (ctor is null) + { + Unknown("object creation with no constructor"); + return None(); + } + var args = ByOrdinal(ctor, creation.Arguments); + if (ctor.IsImplicitlyDeclared) + { + var type = ctor.ContainingType; + StaticAccess(type, "constructor"); + // an implicit constructor runs the instance initializers and the base constructor + if (HasInstanceInitializers(type) + || !(type.IsValueType || type.BaseType?.SpecialType == SpecialType.System_Object)) + Unknown($"implicit constructor of {type.ToDisplayString()}"); + } + else + { + Call(ctor, null, args, creation, nonVirtual: true); + } + return One("heap"); // a new object: its contents are not tracked, so it reads as heap + } + + private SortedSet Call(IMethodSymbol method, IOperation? instance, SortedSet[] args, + IOperation at, bool nonVirtual) + { + // `object()` is empty: the root of every constructor chain + if (method.MethodKind == MethodKind.Constructor && method.ContainingType.SpecialType == SpecialType.System_Object) + return None(); + if (method.ReturnsByRef || method.ReturnsByRefReadonly) + Unknown($"call to ref-returning {Key(method)}"); + if (method.IsStatic || method.MethodKind == MethodKind.Constructor) + StaticAccess(method.ContainingType, "member"); + + var receiver = instance is null ? null : Eval(instance); + string dispatch; + if (method.MethodKind == MethodKind.DelegateInvoke) + dispatch = "delegate"; + else if (!nonVirtual && (method.IsVirtual || method.IsAbstract || method.IsOverride + || method.ContainingType.TypeKind == TypeKind.Interface) + && !(method.IsSealed || method.ContainingType.IsSealed || method.ContainingType.IsValueType)) + dispatch = "virtual"; + else if (method.ContainingType.TypeKind == TypeKind.Interface) + dispatch = "virtual"; // a static abstract member: resolved by a type argument + else if (method.OriginalDefinition.DeclaringSyntaxReferences.IsEmpty) + dispatch = "extern"; + else + dispatch = "direct"; + + var id = _calls.Count; + _calls.Add(new Dictionary + { + ["id"] = id, + ["callee"] = Key(method), + ["dispatch"] = dispatch, + ["receiver"] = receiver?.ToList(), + ["args"] = args.Select(a => a.ToList()).ToList(), + ["line"] = at.Syntax.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + }); + return method.ReturnsVoid || Inert(method.ReturnType) ? None() : One($"call:{id}"); + } + } +} diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index bf923259..c075f86e 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -52,6 +52,8 @@ var rawInputs = new List(); string? outPath = null; +// H0 heap-effect source facts: written to THIS file, never into the facts document. +string? heapEffectsPath = null; // --ref-dir (repeatable, P-014 Tier B): widen the compilation's reference set with the // DLLs under , searched RECURSIVELY — point it at a project's built `bin/` output (or a // restored package's `lib/`) and the SemanticModel can then bind events on third-party types @@ -152,6 +154,8 @@ events bind to real symbols instead of OWN050 (repeatable) --flow-locals path-sensitive flow analysis of non-escaping local IDisposables --stats print flow-locals coverage (requires --flow-locals) --body-throw-edges treat escaping body-level may-throw as a dispose-on-throw point (needs --flow-locals) + --heap-effects FILE also write heap-effect source facts (H0, inert) to FILE; the facts + document is byte-identical with or without it -h, --help show this help and exit """; if (args0.Contains("-h") || args0.Contains("--help")) @@ -184,6 +188,7 @@ events bind to real symbols instead of OWN050 (repeatable) else if (args0[i] == "--flow-locals") flowLocals = true; else if (args0[i] == "--body-throw-edges") BodyThrowEdges = true; else if (args0[i] == "--stats") reportStats = true; + else if (args0[i] == "--heap-effects" && i + 1 < args0.Length) heapEffectsPath = args0[++i]; else rawInputs.Add(args0[i]); } @@ -7332,6 +7337,13 @@ or ImplicitObjectCreationExpressionSyntax } init if (outPath is null) Console.WriteLine(json); else File.WriteAllText(outPath, json); +// H0 (docs/notes/heap-effect-summaries.md): heap-effect source facts for the core's summary +// solver. A separate file, produced after the facts are final, so no flag spelling can move a +// byte of the facts document; nothing in the core reads it for a verdict. +if (heapEffectsPath is not null) + File.WriteAllText(heapEffectsPath, JsonSerializer.Serialize( + HeapEffectFacts.Extract(compilation, parsed), + new JsonSerializerOptions { WriteIndented = true })); return 0; // Opt-in recall knob for the flow pass, read deep in InjectThrowEdge (a static field rather than diff --git a/frontend/roslyn/heap-effects-samples/HeapEffects.cs b/frontend/roslyn/heap-effects-samples/HeapEffects.cs new file mode 100644 index 00000000..ce7eda0e --- /dev/null +++ b/frontend/roslyn/heap-effects-samples/HeapEffects.cs @@ -0,0 +1,152 @@ +using System; + +namespace Own.HeapEffects.Samples; + +// H0 heap-effect summaries (docs/notes/heap-effect-summaries.md): the C# behind the committed +// sidecar `tests/fixtures/heap_effects/samples.sidecar.json` and its solved golden +// `samples.summaries.json`. scripts/heap_effects_gate.py re-extracts this file and requires +// the committed sidecar back, byte-equal as JSON. Lives under frontend/roslyn/ (not examples/) +// so the #260 shadow sweep does not treat it as an application. + +public sealed class Order +{ + public int State { get; set; } + public Order? Next { get; set; } + + // receiver effects + public void Touch() => State = 1; + public int Peek() => State; +} + +public interface IShape +{ + int Area(); +} + +public static class Kill +{ + public static Order? Saved; + public static int Counter; + + // 1. harmless candidate: every parameter plain, no writes, nothing returned that aliases + public static int Twice(int x) => x * 2; + + // 2. BorrowMut(0) + public static void Mutates(Order x) => x.State = 1; + + // 3. MayEscape(0) + a static write + public static void Escapes(Order x) => Saved = x; + + // 4. a static write, no parameter at all + public static void TouchesGlobalState() => Counter++; + + // 5. an external call: Unknown (nothing proves what Console does) + public static void Logs() => Console.WriteLine("x"); +} + +public static class Transitive +{ + // A(x) => B(x); B mutates / escapes: the effect reaches A only through B's summary + public static void A(Order x) => B(x); + public static void B(Order x) => x.State = 2; + + public static void EscapeOuter(Order x) => EscapeInner(x); + public static void EscapeInner(Order x) => Kill.Saved = x; + + // Unknown propagates: a caller of an unknown is unknown where it matters + public static void CallsLogs() => Kill.Logs(); + + // receiver effect, mapped to the argument that is the receiver + public static void CallsTouch(Order o) => o.Touch(); + public static int CallsPeek(Order o) => o.Peek(); + + // local alias: the write is to the parameter's object + public static void AliasMutates(Order o) + { + var a = o; + a.State = 3; + } + + // a reassigned parameter: its later value may be the heap's + public static void Reassigned(Order o) + { + o = Kill.Saved!; + o.State = 4; + } + + // array element: the array is mutated, the stored value escapes + public static void SetFirst(Order[] items, Order o) => items[0] = o; + + // reads only + public static int ReadsDeep(Order o) => o.Next!.State; +} + +public static class Returns +{ + public static Order Id(Order o) => o; + public static Order ViaCall(Order o) => Id(o); + public static Order? FromHeap() => Kill.Saved; + public static Order Fresh() => new Order(); + public static void MutatesResult(Order o) => Id(o).State = 5; +} + +public static class Recursive +{ + // a pure SCC: stays plain + public static bool Even(int n) => n == 0 || Odd(n - 1); + public static bool Odd(int n) => n != 0 && Even(n - 1); + + // an SCC whose one member mutates: both carry it + public static void Ping(Order o, int n) + { + if (n > 0) + Pong(o, n - 1); + } + + public static void Pong(Order o, int n) + { + o.State = n; + Ping(o, n); + } + + // an SCC with an unknown member: Unknown absorbs the whole cycle + public static void Tick(Order o, int n) + { + if (n > 0) + Tock(o, n - 1); + } + + public static void Tock(Order o, int n) + { + Console.WriteLine(n); + Tick(o, n); + } +} + +public static class Opaque +{ + // virtual dispatch: the override is not known + public static int Virtual(IShape s) => s.Area(); + + // a lambda: unmodelled, Unknown + public static Func Lambda(Order o) => () => o.State; + + // a static constructor runs on first touch of the type + public static int CallsLazy() => Lazy.Value(); +} + +public static class Lazy +{ + private static readonly int Seed; + + static Lazy() => Seed = Environment.ProcessorCount; + + public static int Value() => Seed; +} + +// A primary-constructor parameter is hidden state of the instance, not a parameter of the +// member that reads it: Unknown, never mistaken for the member's own argument. +public sealed class Holder(Order order) +{ + public void Touch() => order.State = 6; +} diff --git a/ownlang/heap_effects.py b/ownlang/heap_effects.py new file mode 100644 index 00000000..ddb05fec --- /dev/null +++ b/ownlang/heap_effects.py @@ -0,0 +1,516 @@ +"""Heap-effect summaries — H0 (docs/notes/heap-effect-summaries.md). INERT. + +What a source-visible method may do to memory its caller can see, solved from the +extractor's heap-effect SOURCE FACTS (`ownsharp-extract --heap-effects FILE`) by a +least fixpoint over the call graph's SCC condensation. It is a second summary +domain beside the Method Ownership Summary (`ownership.py`), in its own document: +nothing here reads OwnIR facts, and nothing in the checker reads this. No verdict, +refusal or diagnostic depends on it (H0 is inert by construction; H1 is the first +consumer, and it is not written). + +THE DOMAIN, per method: + + params[i].effect, receiver plain < borrow < borrow_mut < may_escape < unknown + plain the argument's object graph is not touched (every inert value is plain); + borrow it is read through; + borrow_mut it may be written through (a field, an element, a ref/out location); + may_escape it may be stored where it outlives the call — after which anybody may + write it, so may_escape subsumes borrow_mut on this chain; + unknown nothing proves less. + writes.{instance,static,indirect} none < may < unknown — writes to memory reached + from NEITHER the parameters NOR the receiver: an object's field (instance), a + static field (static), an array element or other indirect storage (indirect). + A write through a parameter is that parameter's borrow_mut, not a `writes` entry. + returns [] (nothing the caller can reach through, or an inert value) | some of + param:, receiver, heap | ["unknown"]. + unresolved the method's OWN reasons for an Unknown (an unmodelled construct, a call + that is extern / virtual / through a delegate / to a method with no record). + Local, for evidence; what is transitively unknown shows in the effects. + +UNKNOWN ABSORBS wherever silence would read as clean: a method with an unmodelled +construct is Unknown on every non-inert parameter, its receiver, every write kind and +its return; a call nothing summarizes makes every argument and the receiver Unknown +and every write kind Unknown; a value that came back from such a call is an Unknown +root, and a write through it is an Unknown write. + +Determinism (the parity contract with `own-bridge/src/heap_effects.rs`): the solution +is the least fixpoint of a monotone system over finite lattices, so it is independent +of iteration order; the dump sorts summaries by key and serializes +`json.dumps(indent=2, sort_keys=True)` plus a newline. The Rust port reproduces the +dump byte-for-byte (tests/test_heap_effects_fixtures.py, own-bridge/tests/heap_effects.rs). + +Run: python -m ownlang.heap_effects sidecar.json (print the solved summaries) +""" + +from __future__ import annotations + +import json +import re +import sys +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any + +HEAP_EFFECTS_VERSION = 1 + +EFFECTS = ("plain", "borrow", "borrow_mut", "may_escape", "unknown") +PLAIN, BORROW, BORROW_MUT, MAY_ESCAPE, UNKNOWN = range(5) +WRITES = ("none", "may", "unknown") +W_NONE, W_MAY, W_UNKNOWN = range(3) +WRITE_KINDS = ("instance", "static", "indirect") +DISPATCHES = ("direct", "virtual", "extern", "delegate") + +_TOKEN = re.compile(r"(param|local|call):(0|[1-9][0-9]*)") +_MAX_LINE = 2147483647 + + +class HeapEffectsError(Exception): + """The sidecar does not satisfy its own vocabulary. The message is the parity text.""" + + +# --- the source facts ------------------------------------------------------- + + +@dataclass(frozen=True) +class Call: + callee: str + dispatch: str + receiver: tuple[str, ...] | None + args: tuple[tuple[str, ...], ...] + + +@dataclass(frozen=True) +class Method: + key: str + file: str + line: int + receiver: bool + return_inert: bool + params: tuple[tuple[str, bool], ...] # (name, inert), by index + locals: tuple[tuple[str, ...], ...] # sources, by id + derefs: tuple[str, ...] + writes: tuple[tuple[str, tuple[str, ...]], ...] + stores: tuple[str, ...] + returns: tuple[str, ...] + calls: tuple[Call, ...] + unknown: tuple[str, ...] + + +def _fail(where: str, what: str) -> HeapEffectsError: + return HeapEffectsError(f"heap-effect facts: {where}: {what}") + + +def _is_int(v: object) -> bool: + """An integer both JSON readers hold exactly (the Rust port reads 64-bit integers; + anything wider it reads as a float, so it is a wrong type on both sides).""" + return type(v) is int and -(2 ** 63) <= v <= 2 ** 64 - 1 + + +def _strings(v: object) -> tuple[str, ...] | None: + if not isinstance(v, list) or not all(isinstance(x, str) for x in v): + return None + return tuple(v) + + +def load(doc: object) -> list[Method]: + """Validate a sidecar document and return its methods (in document order). + + Validation order IS the parity contract: the first violation in this order is the + one reported, by both engines, with the same text.""" + if not isinstance(doc, dict): + raise HeapEffectsError("heap-effect facts: the document is not an object") + version = doc.get("heap_effects_version") + if not _is_int(version) or version != HEAP_EFFECTS_VERSION: + raise HeapEffectsError( + f"heap-effect facts: heap_effects_version must be {HEAP_EFFECTS_VERSION}") + raw = doc.get("methods") + if not isinstance(raw, list) or not all(isinstance(m, dict) for m in raw): + raise HeapEffectsError("heap-effect facts: methods must be an array of objects") + + methods: list[Method] = [] + seen: set[str] = set() + for i, m in enumerate(raw): + where = f"method #{i}" + + def bad(field: str, where: str = where) -> HeapEffectsError: + return _fail(where, f"field '{field}' is missing or has the wrong type") + + key = m.get("key") + if not isinstance(key, str) or not key: + raise bad("key") + if key in seen: + raise _fail(where, "duplicate key") + seen.add(key) + file = m.get("file") + if not isinstance(file, str): + raise bad("file") + line = m.get("line") + if not _is_int(line) or not 0 <= line <= _MAX_LINE: + raise bad("line") + receiver = m.get("receiver") + if not isinstance(receiver, bool): + raise bad("receiver") + return_inert = m.get("return_inert") + if not isinstance(return_inert, bool): + raise bad("return_inert") + + raw_params = m.get("params") + if not isinstance(raw_params, list): + raise bad("params") + params: list[tuple[str, bool]] = [] + for j, p in enumerate(raw_params): + if (not isinstance(p, dict) or not _is_int(p.get("index")) + or not isinstance(p.get("name"), str) or not isinstance(p.get("inert"), bool)): + raise bad("params") + if p["index"] != j: + raise _fail(where, f"params[{j}] is out of sequence") + params.append((p["name"], p["inert"])) + + raw_locals = m.get("locals") + if not isinstance(raw_locals, list): + raise bad("locals") + local_sources: list[tuple[str, ...]] = [] + for j, loc in enumerate(raw_locals): + sources = _strings(loc.get("sources")) if isinstance(loc, dict) else None + if (not isinstance(loc, dict) or not _is_int(loc.get("id")) + or not isinstance(loc.get("name"), str) or sources is None): + raise bad("locals") + if loc["id"] != j: + raise _fail(where, f"locals[{j}] is out of sequence") + local_sources.append(sources) + + raw_calls = m.get("calls") + if not isinstance(raw_calls, list): + raise bad("calls") + calls: list[Call] = [] + for j, c in enumerate(raw_calls): + if not isinstance(c, dict): + raise bad("calls") + args_raw = c.get("args") + args = (tuple(_strings(a) for a in args_raw) + if isinstance(args_raw, list) else None) + recv_raw = c.get("receiver") + recv = None if recv_raw is None else _strings(recv_raw) + if (not _is_int(c.get("id")) or not isinstance(c.get("callee"), str) + or not isinstance(c.get("dispatch"), str) + or not _is_int(c.get("line")) or not 0 <= c["line"] <= _MAX_LINE + or (recv_raw is not None and recv is None) + or args is None or any(a is None for a in args)): + raise bad("calls") + if c["id"] != j: + raise _fail(where, f"calls[{j}] is out of sequence") + if c["dispatch"] not in DISPATCHES: + raise _fail(where, f"calls[{j}] has an unknown dispatch") + calls.append(Call(c["callee"], c["dispatch"], recv, + tuple(a for a in args if a is not None))) + + raw_writes = m.get("writes") + if not isinstance(raw_writes, list): + raise bad("writes") + writes: list[tuple[str, tuple[str, ...]]] = [] + for j, w in enumerate(raw_writes): + target = _strings(w.get("target")) if isinstance(w, dict) else None + if not isinstance(w, dict) or not isinstance(w.get("kind"), str) or target is None: + raise bad("writes") + if w["kind"] not in WRITE_KINDS: + raise _fail(where, f"writes[{j}] has an unknown kind") + writes.append((w["kind"], target)) + + lists: dict[str, tuple[str, ...]] = {} + for field in ("derefs", "stores", "returns", "unknown"): + value = _strings(m.get(field)) + if value is None: + raise bad(field) + lists[field] = value + + method = Method( + key=key, file=file, line=line, receiver=receiver, return_inert=return_inert, + params=tuple(params), locals=tuple(local_sources), derefs=lists["derefs"], + writes=tuple(writes), stores=lists["stores"], returns=lists["returns"], + calls=tuple(calls), unknown=lists["unknown"]) + _check_tokens(method, where) + methods.append(method) + + by_key = {m.key: m for m in methods} + for i, m in enumerate(methods): + for j, c in enumerate(m.calls): + callee = by_key.get(c.callee) + if c.dispatch == "direct" and callee is not None and len(c.args) != len(callee.params): + raise _fail(f"method #{i}", + f"calls[{j}] has {len(c.args)} argument slots, " + f"its callee takes {len(callee.params)}") + return methods + + +def _check_tokens(m: Method, where: str) -> None: + def check(tokens: Iterable[str], field: str) -> None: + for t in tokens: + if t == "heap" or (t == "receiver" and m.receiver): + continue + match = _TOKEN.fullmatch(t) + limit = {"param": len(m.params), "local": len(m.locals), + "call": len(m.calls)}.get(match.group(1), 0) if match else 0 + if match is None or int(match.group(2)) >= limit: + raise _fail(where, f"{field} holds a token outside the vocabulary") + + for j, sources in enumerate(m.locals): + check(sources, f"locals[{j}]") + check(m.derefs, "derefs") + for j, (_, target) in enumerate(m.writes): + check(target, f"writes[{j}]") + check(m.stores, "stores") + check(m.returns, "returns") + for j, c in enumerate(m.calls): + if c.receiver is not None: + check(c.receiver, f"calls[{j}].receiver") + for k, arg in enumerate(c.args): + check(arg, f"calls[{j}].args[{k}]") + + +# --- the solved summary ------------------------------------------------------ + + +@dataclass(frozen=True) +class Summary: + params: tuple[int, ...] + receiver: int | None + writes: tuple[int, int, int] # instance, static, indirect + returns: tuple[str, ...] + + +def _bottom(m: Method) -> Summary: + return Summary(tuple(PLAIN for _ in m.params), PLAIN if m.receiver else None, + (W_NONE, W_NONE, W_NONE), ()) + + +def _callee_unknown(c: Call, methods: dict[str, Method]) -> bool: + return c.dispatch != "direct" or c.callee not in methods + + +def _evaluate(m: Method, methods: dict[str, Method], solved: dict[str, Summary]) -> Summary: + """One application of the method's transfer function to its callees' current + summaries. Monotone in them; recomputed from scratch each time.""" + if m.unknown: + return Summary( + tuple(PLAIN if inert else UNKNOWN for _, inert in m.params), + UNKNOWN if m.receiver else None, + (W_UNKNOWN, W_UNKNOWN, W_UNKNOWN), + () if m.return_inert else ("unknown",)) + + params = [PLAIN for _ in m.params] + receiver = [PLAIN] if m.receiver else [] + writes = [W_NONE, W_NONE, W_NONE] + returns: set[str] = set() + + def roots(tokens: Iterable[str]) -> set[str]: + """The roots a value may be: param:, receiver, heap, unknown.""" + out: set[str] = set() + stack = list(tokens) + seen: set[str] = set() + while stack: + t = stack.pop() + if t in seen: + continue + seen.add(t) + if t in ("receiver", "heap", "unknown") or t.startswith("param:"): + out.add(t) + elif t.startswith("local:"): + stack.extend(m.locals[int(t[6:])]) + else: # call: + c = m.calls[int(t[5:])] + if _callee_unknown(c, methods): + out.add("unknown") + continue + for r in solved[c.callee].returns: + if r.startswith("param:"): + stack.extend(c.args[int(r[6:])]) + elif r == "receiver": + # a constructor's receiver is the new object: heap to the caller + stack.extend(c.receiver if c.receiver is not None else ("heap",)) + else: + out.add(r) + return out + + def apply(tokens: Iterable[str], effect: int, kind: int) -> None: + for r in roots(tokens): + if r.startswith("param:"): + i = int(r[6:]) + if not m.params[i][1]: + params[i] = max(params[i], effect) + elif r == "receiver": + receiver[0] = max(receiver[0], effect) + elif effect >= BORROW_MUT: + # an object reached from neither a parameter nor the receiver + level = W_UNKNOWN if (r == "unknown" or effect == UNKNOWN) else W_MAY + writes[kind] = max(writes[kind], level) + + apply(m.derefs, BORROW, 0) + for kind, target in m.writes: + if kind == "static": + writes[1] = max(writes[1], W_MAY) + else: + apply(target, BORROW_MUT, WRITE_KINDS.index(kind)) + apply(m.stores, MAY_ESCAPE, 0) + for c in m.calls: + if _callee_unknown(c, methods): + for arg in c.args: + apply(arg, UNKNOWN, 0) + if c.receiver is not None: + apply(c.receiver, UNKNOWN, 0) + writes = [W_UNKNOWN, W_UNKNOWN, W_UNKNOWN] + continue + s = solved[c.callee] + for j, effect in enumerate(s.params): + apply(c.args[j], effect, 0) + if s.receiver is not None and c.receiver is not None: + apply(c.receiver, s.receiver, 0) + writes = [max(a, b) for a, b in zip(writes, s.writes, strict=True)] + if not m.return_inert: + for r in roots(m.returns): + if r.startswith("param:") and m.params[int(r[6:])][1]: + continue + returns.add(r) + + return Summary( + tuple(params), receiver[0] if m.receiver else None, + (writes[0], writes[1], writes[2]), + ("unknown",) if "unknown" in returns else tuple(sorted(returns))) + + +def _sccs(keys: list[str], edges: dict[str, list[str]]) -> list[list[str]]: + """Tarjan, iterative, bottom-up (every component before its callers).""" + index: dict[str, int] = {} + low: dict[str, int] = {} + on_stack: set[str] = set() + stack: list[str] = [] + out: list[list[str]] = [] + counter = 0 + for root in keys: + if root in index: + continue + work: list[tuple[str, int]] = [(root, 0)] + while work: + v, pos = work.pop() + if pos == 0: + index[v] = low[v] = counter + counter += 1 + stack.append(v) + on_stack.add(v) + succ = edges[v] + if pos < len(succ): + work.append((v, pos + 1)) + w = succ[pos] + if w not in index: + work.append((w, 0)) + elif w in on_stack: + low[v] = min(low[v], index[w]) + continue + if low[v] == index[v]: + comp: list[str] = [] + while True: + w = stack.pop() + on_stack.discard(w) + comp.append(w) + if w == v: + break + out.append(sorted(comp)) + if work: + parent = work[-1][0] + low[parent] = min(low[parent], low[v]) + return out + + +def solve(methods: list[Method]) -> dict[str, Summary]: + by_key = {m.key: m for m in methods} + keys = sorted(by_key) + edges = {k: sorted({c.callee for c in by_key[k].calls + if not _callee_unknown(c, by_key)}) for k in keys} + solved = {k: _bottom(by_key[k]) for k in keys} + for comp in _sccs(keys, edges): + changed = True + while changed: + changed = False + for k in comp: + new = _evaluate(by_key[k], by_key, solved) + if new != solved[k]: + solved[k] = new + changed = True + return solved + + +def _unresolved(m: Method, methods: dict[str, Method]) -> list[str]: + reasons = set(m.unknown) + for c in m.calls: + if c.dispatch != "direct": + reasons.add(f"{c.callee} ({c.dispatch})") + elif c.callee not in methods: + reasons.add(f"{c.callee} (no summary)") + return sorted(reasons) + + +def dump(doc: object) -> dict[str, Any]: + """The solved document (raises HeapEffectsError on a sidecar that fails `load`).""" + methods = load(doc) + by_key = {m.key: m for m in methods} + solved = solve(methods) + summaries = [] + for key in sorted(by_key): + m, s = by_key[key], solved[key] + summaries.append({ + "method": key, + "file": m.file, + "line": m.line, + "params": [{"index": i, "name": name, "effect": EFFECTS[s.params[i]]} + for i, (name, _) in enumerate(m.params)], + "receiver": None if s.receiver is None else EFFECTS[s.receiver], + "writes": {kind: WRITES[s.writes[i]] for i, kind in enumerate(WRITE_KINDS)}, + "returns": list(s.returns), + "unresolved": _unresolved(m, by_key), + }) + return {"heap_effects_version": HEAP_EFFECTS_VERSION, "summaries": summaries} + + +def _reject_constant(_: str) -> object: + raise ValueError("not RFC 8259") + + +def _strict_float(text: str) -> float: + value = float(text) + if value in (float("inf"), float("-inf")): + raise ValueError("out of range") + return value + + +def render(text: str) -> str: + """Sidecar JSON text -> the dump bytes (`json.dumps(indent=2, sort_keys=True)` + newline). + + The JSON reader is held to RFC 8259 — no NaN/Infinity, no overflowing number, no lone + surrogate — the input both engines accept identically.""" + try: + doc = json.loads(text, parse_constant=_reject_constant, parse_float=_strict_float) + json.dumps(doc, ensure_ascii=False).encode("utf-8") + except (ValueError, UnicodeEncodeError): + raise HeapEffectsError("heap-effect facts: not valid JSON") from None + return json.dumps(dump(doc), indent=2, sort_keys=True) + "\n" + + +def main(argv: list[str]) -> int: + if len(argv) != 1: + print("usage: python -m ownlang.heap_effects ", file=sys.stderr) + return 2 + try: + with open(argv[0], "rb") as f: + raw = f.read() + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + raise HeapEffectsError("heap-effect facts: not valid JSON") from None + sys.stdout.write(render(text)) + except (OSError, HeapEffectsError) as e: + print(f"error: {e}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/rust/crates/own-bridge/src/dump.rs b/rust/crates/own-bridge/src/dump.rs index 299c3c70..b9e37b97 100644 --- a/rust/crates/own-bridge/src/dump.rs +++ b/rust/crates/own-bridge/src/dump.rs @@ -153,7 +153,7 @@ fn escape_py(s: &str, out: &mut String) { /// `json.dumps(value, indent=2, sort_keys=True)`: 2-space indent, `": "` / /// `","` separators, keys sorted at every level, empty containers inline. -fn emit(v: &Value, indent: usize, out: &mut String) { +pub(crate) fn emit(v: &Value, indent: usize, out: &mut String) { match v { Value::Null => out.push_str("null"), Value::Bool(true) => out.push_str("true"), diff --git a/rust/crates/own-bridge/src/heap_effects.rs b/rust/crates/own-bridge/src/heap_effects.rs new file mode 100644 index 00000000..ec8f7c42 --- /dev/null +++ b/rust/crates/own-bridge/src/heap_effects.rs @@ -0,0 +1,825 @@ +//! Heap-effect summaries — H0, the port of `ownlang/heap_effects.py` +//! (docs/notes/heap-effect-summaries.md). INERT: nothing in the bridge's +//! verdict path reads it. +//! +//! The input is the extractor's heap-effect SOURCE FACTS (`ownsharp-extract +//! --heap-effects FILE`), not `OwnIR`: a separate document, so this module +//! takes JSON text rather than an [`own_ir::OwnIr`]. It sits beside `mos.rs` +//! for the same reason the MOS solver does — it is a summary layer of the +//! reference port, solved by a least fixpoint over the call graph's SCC +//! condensation — and it shares the dump emitter of `dump.rs`. +//! +//! The parity contract is BYTE-EXACT: the same sidecar text yields the same +//! dump bytes as `python -m ownlang.heap_effects`, and a rejected sidecar +//! yields the same message text (the validation ORDER is part of that +//! contract and mirrors the reference statement for statement). +//! +//! Lattices (see the reference module doc for their meaning): +//! +//! ```text +//! effect plain < borrow < borrow_mut < may_escape < unknown +//! writes none < may < unknown (instance, static, indirect) +//! returns sorted root strings, or ["unknown"] (absorbing) +//! ``` + +// `redundant_pub_crate` (nursery) conflicts with the workspace's DENY of +// `unreachable_pub` for items in private modules; pub(crate) is the honest +// visibility here (same stance as `mos.rs`). `too_many_lines`: `load` mirrors +// the reference's single validation pass so the two orders can be read side by +// side. +#![allow(clippy::redundant_pub_crate, clippy::too_many_lines)] + +use crate::dump::emit as emit_py; +use crate::BridgeError; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +const VERSION: i64 = 1; +const MAX_LINE: i64 = 2_147_483_647; + +const PLAIN: u8 = 0; +const BORROW: u8 = 1; +const BORROW_MUT: u8 = 2; +const MAY_ESCAPE: u8 = 3; +const UNKNOWN: u8 = 4; +const EFFECTS: [&str; 5] = ["plain", "borrow", "borrow_mut", "may_escape", "unknown"]; + +const W_NONE: u8 = 0; +const W_MAY: u8 = 1; +const W_UNKNOWN: u8 = 2; +const WRITES: [&str; 3] = ["none", "may", "unknown"]; +const WRITE_KINDS: [&str; 3] = ["instance", "static", "indirect"]; +const DISPATCHES: [&str; 4] = ["direct", "virtual", "extern", "delegate"]; + +/// A value's source, as the extractor spells it (validated at load). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum Tok { + Param(usize), + Receiver, + Heap, + Local(usize), + Call(usize), +} + +/// What a value may be once locals and call results are resolved. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum Root { + Param(usize), + Receiver, + Heap, + Unknown, +} + +impl Root { + fn render(self) -> String { + match self { + Self::Param(i) => format!("param:{i}"), + Self::Receiver => "receiver".to_owned(), + Self::Heap => "heap".to_owned(), + Self::Unknown => "unknown".to_owned(), + } + } + + fn parse(s: &str) -> Option { + match s { + "receiver" => Some(Self::Receiver), + "heap" => Some(Self::Heap), + "unknown" => Some(Self::Unknown), + _ => s + .strip_prefix("param:") + .and_then(|n| n.parse().ok()) + .map(Self::Param), + } + } +} + +#[derive(Debug)] +struct Call { + callee: String, + dispatch: String, + receiver: Option>, + args: Vec>, +} + +#[derive(Debug)] +struct Method { + key: String, + file: String, + line: i64, + receiver: bool, + return_inert: bool, + params: Vec<(String, bool)>, + locals: Vec>, + derefs: Vec, + writes: Vec<(usize, Vec)>, + stores: Vec, + returns: Vec, + calls: Vec, + unknown: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Summary { + params: Vec, + receiver: Option, + writes: [u8; 3], + /// Root strings, sorted as strings (the reference's `sorted`). + returns: Vec, +} + +fn fail(where_: &str, what: &str) -> BridgeError { + BridgeError(format!("heap-effect facts: {where_}: {what}")) +} + +/// Python `type(v) is int` over the integers both JSON readers agree on. +fn int_of(v: Option<&Value>) -> Option { + let n = v?.as_number()?; + if let Some(i) = n.as_i64() { + return Some(i); + } + // a u64 above i64::MAX is still an integer to the reference; it is never + // a valid index or line, so any out-of-range stand-in reads the same + n.as_u64().map(|_| i64::MAX) +} + +fn strings(v: Option<&Value>) -> Option> { + v?.as_array()? + .iter() + .map(|x| x.as_str().map(str::to_owned)) + .collect() +} + +/// One call before its tokens are validated: callee, dispatch, receiver, args. +type RawCall = (String, String, Option>, Vec>); + +/// The raw text of every list field, validated as tokens once counts are known. +struct RawMethod { + key: String, + file: String, + line: i64, + receiver: bool, + return_inert: bool, + params: Vec<(String, bool)>, + locals: Vec>, + calls: Vec, + writes: Vec<(usize, Vec)>, + derefs: Vec, + stores: Vec, + returns: Vec, + unknown: Vec, +} + +fn load(doc: &Value) -> Result, BridgeError> { + let Some(root) = doc.as_object() else { + return Err(BridgeError( + "heap-effect facts: the document is not an object".to_owned(), + )); + }; + if int_of(root.get("heap_effects_version")) != Some(VERSION) { + return Err(BridgeError(format!( + "heap-effect facts: heap_effects_version must be {VERSION}" + ))); + } + let raw: Vec<&Map> = match root.get("methods").and_then(Value::as_array) { + Some(items) => match items.iter().map(Value::as_object).collect::>() { + Some(objects) => objects, + None => return Err(methods_shape()), + }, + None => return Err(methods_shape()), + }; + + let mut methods = Vec::with_capacity(raw.len()); + let mut seen: HashSet = HashSet::new(); + for (i, m) in raw.iter().enumerate() { + let where_ = format!("method #{i}"); + let bad = |field: &str| { + fail( + &where_, + &format!("field '{field}' is missing or has the wrong type"), + ) + }; + + let key = match m.get("key").and_then(Value::as_str) { + Some(k) if !k.is_empty() => k.to_owned(), + _ => return Err(bad("key")), + }; + if !seen.insert(key.clone()) { + return Err(fail(&where_, "duplicate key")); + } + let file = m + .get("file") + .and_then(Value::as_str) + .ok_or_else(|| bad("file"))? + .to_owned(); + let line = match int_of(m.get("line")) { + Some(l) if (0..=MAX_LINE).contains(&l) => l, + _ => return Err(bad("line")), + }; + let receiver = m + .get("receiver") + .and_then(Value::as_bool) + .ok_or_else(|| bad("receiver"))?; + let return_inert = m + .get("return_inert") + .and_then(Value::as_bool) + .ok_or_else(|| bad("return_inert"))?; + + let raw_params = m + .get("params") + .and_then(Value::as_array) + .ok_or_else(|| bad("params"))?; + let mut params = Vec::with_capacity(raw_params.len()); + for (j, p) in raw_params.iter().enumerate() { + let p = p.as_object().ok_or_else(|| bad("params"))?; + let index = int_of(p.get("index")); + let name = p.get("name").and_then(Value::as_str); + let inert = p.get("inert").and_then(Value::as_bool); + let (Some(index), Some(name), Some(inert)) = (index, name, inert) else { + return Err(bad("params")); + }; + if !same(index, j) { + return Err(fail(&where_, &format!("params[{j}] is out of sequence"))); + } + params.push((name.to_owned(), inert)); + } + + let raw_locals = m + .get("locals") + .and_then(Value::as_array) + .ok_or_else(|| bad("locals"))?; + let mut locals = Vec::with_capacity(raw_locals.len()); + for (j, l) in raw_locals.iter().enumerate() { + let l = l.as_object().ok_or_else(|| bad("locals"))?; + let id = int_of(l.get("id")); + let named = l.get("name").and_then(Value::as_str).is_some(); + let sources = strings(l.get("sources")); + let (Some(id), true, Some(sources)) = (id, named, sources) else { + return Err(bad("locals")); + }; + if !same(id, j) { + return Err(fail(&where_, &format!("locals[{j}] is out of sequence"))); + } + locals.push(sources); + } + + let raw_calls = m + .get("calls") + .and_then(Value::as_array) + .ok_or_else(|| bad("calls"))?; + let mut calls = Vec::with_capacity(raw_calls.len()); + for (j, c) in raw_calls.iter().enumerate() { + let c = c.as_object().ok_or_else(|| bad("calls"))?; + let args: Option>> = c + .get("args") + .and_then(Value::as_array) + .and_then(|a| a.iter().map(|x| strings(Some(x))).collect()); + let recv_raw = c.get("receiver"); + let recv = match recv_raw { + None | Some(Value::Null) => Some(None), + Some(v) => strings(Some(v)).map(Some), + }; + let id = int_of(c.get("id")); + let callee = c.get("callee").and_then(Value::as_str); + let dispatch = c.get("dispatch").and_then(Value::as_str); + let line_ok = matches!(int_of(c.get("line")), Some(l) if (0..=MAX_LINE).contains(&l)); + let (Some(id), Some(callee), Some(dispatch), true, Some(recv), Some(args)) = + (id, callee, dispatch, line_ok, recv, args) + else { + return Err(bad("calls")); + }; + if !same(id, j) { + return Err(fail(&where_, &format!("calls[{j}] is out of sequence"))); + } + if !DISPATCHES.contains(&dispatch) { + return Err(fail( + &where_, + &format!("calls[{j}] has an unknown dispatch"), + )); + } + calls.push((callee.to_owned(), dispatch.to_owned(), recv, args)); + } + + let raw_writes = m + .get("writes") + .and_then(Value::as_array) + .ok_or_else(|| bad("writes"))?; + let mut writes = Vec::with_capacity(raw_writes.len()); + for (j, w) in raw_writes.iter().enumerate() { + let w = w.as_object().ok_or_else(|| bad("writes"))?; + let kind = w.get("kind").and_then(Value::as_str); + let target = strings(w.get("target")); + let (Some(kind), Some(target)) = (kind, target) else { + return Err(bad("writes")); + }; + let Some(k) = WRITE_KINDS.iter().position(|x| *x == kind) else { + return Err(fail(&where_, &format!("writes[{j}] has an unknown kind"))); + }; + writes.push((k, target)); + } + + let derefs = strings(m.get("derefs")).ok_or_else(|| bad("derefs"))?; + let stores = strings(m.get("stores")).ok_or_else(|| bad("stores"))?; + let returns = strings(m.get("returns")).ok_or_else(|| bad("returns"))?; + let unknown = strings(m.get("unknown")).ok_or_else(|| bad("unknown"))?; + + let raw = RawMethod { + key, + file, + line, + receiver, + return_inert, + params, + locals, + calls, + writes, + derefs, + stores, + returns, + unknown, + }; + methods.push(tokens(raw, &where_)?); + } + + let by_key: HashMap<&str, &Method> = methods.iter().map(|m| (m.key.as_str(), m)).collect(); + for (i, m) in methods.iter().enumerate() { + for (j, c) in m.calls.iter().enumerate() { + if c.dispatch != "direct" { + continue; + } + if let Some(callee) = by_key.get(c.callee.as_str()) { + if c.args.len() != callee.params.len() { + return Err(fail( + &format!("method #{i}"), + &format!( + "calls[{j}] has {} argument slots, its callee takes {}", + c.args.len(), + callee.params.len() + ), + )); + } + } + } + } + Ok(methods) +} + +fn methods_shape() -> BridgeError { + BridgeError("heap-effect facts: methods must be an array of objects".to_owned()) +} + +fn same(value: i64, position: usize) -> bool { + i64::try_from(position).is_ok_and(|p| p == value) +} + +/// Token validation, in the reference's `_check_tokens` order. +fn tokens(raw: RawMethod, where_: &str) -> Result { + let counts = (raw.params.len(), raw.locals.len(), raw.calls.len()); + let receiver = raw.receiver; + let check = |list: &[String], field: &str| -> Result, BridgeError> { + list.iter() + .map(|t| { + parse_tok(t, receiver, counts).ok_or_else(|| { + fail( + where_, + &format!("{field} holds a token outside the vocabulary"), + ) + }) + }) + .collect() + }; + let mut locals = Vec::with_capacity(raw.locals.len()); + for (j, sources) in raw.locals.iter().enumerate() { + locals.push(check(sources, &format!("locals[{j}]"))?); + } + let derefs = check(&raw.derefs, "derefs")?; + let mut writes = Vec::with_capacity(raw.writes.len()); + for (j, (kind, target)) in raw.writes.iter().enumerate() { + writes.push((*kind, check(target, &format!("writes[{j}]"))?)); + } + let stores = check(&raw.stores, "stores")?; + let returns = check(&raw.returns, "returns")?; + let mut calls = Vec::with_capacity(raw.calls.len()); + for (j, (callee, dispatch, recv, args)) in raw.calls.into_iter().enumerate() { + let receiver = match recv { + Some(r) => Some(check(&r, &format!("calls[{j}].receiver"))?), + None => None, + }; + let mut checked = Vec::with_capacity(args.len()); + for (k, arg) in args.iter().enumerate() { + checked.push(check(arg, &format!("calls[{j}].args[{k}]"))?); + } + calls.push(Call { + callee, + dispatch, + receiver, + args: checked, + }); + } + Ok(Method { + key: raw.key, + file: raw.file, + line: raw.line, + receiver: raw.receiver, + return_inert: raw.return_inert, + params: raw.params, + locals, + derefs, + writes, + stores, + returns, + calls, + unknown: raw.unknown, + }) +} + +/// `(param|local|call):(0|[1-9][0-9]*)` below its count, `heap`, or +/// `receiver` on a method that has one. +fn parse_tok(t: &str, receiver: bool, counts: (usize, usize, usize)) -> Option { + if t == "heap" { + return Some(Tok::Heap); + } + if t == "receiver" { + return receiver.then_some(Tok::Receiver); + } + let (kind, digits) = t.split_once(':')?; + let canonical = !digits.is_empty() + && digits.bytes().all(|b| b.is_ascii_digit()) + && (digits == "0" || !digits.starts_with('0')); + if !canonical { + return None; + } + // a number too large for usize is past every count + let n: usize = digits.parse().ok()?; + match kind { + "param" if n < counts.0 => Some(Tok::Param(n)), + "local" if n < counts.1 => Some(Tok::Local(n)), + "call" if n < counts.2 => Some(Tok::Call(n)), + _ => None, + } +} + +// --- the solver ------------------------------------------------------------ + +fn bottom(m: &Method) -> Summary { + Summary { + params: vec![PLAIN; m.params.len()], + receiver: m.receiver.then_some(PLAIN), + writes: [W_NONE; 3], + returns: Vec::new(), + } +} + +fn callee_unknown(c: &Call, methods: &HashMap<&str, &Method>) -> bool { + c.dispatch != "direct" || !methods.contains_key(c.callee.as_str()) +} + +struct Eval<'a> { + m: &'a Method, + methods: &'a HashMap<&'a str, &'a Method>, + solved: &'a HashMap, + params: Vec, + receiver: u8, + writes: [u8; 3], +} + +impl Eval<'_> { + fn roots(&self, start: &[Tok]) -> HashSet { + let mut out = HashSet::new(); + let mut stack: Vec = start.to_vec(); + let mut seen: HashSet = HashSet::new(); + while let Some(t) = stack.pop() { + if !seen.insert(t) { + continue; + } + match t { + Tok::Param(i) => { + out.insert(Root::Param(i)); + } + Tok::Receiver => { + out.insert(Root::Receiver); + } + Tok::Heap => { + out.insert(Root::Heap); + } + Tok::Local(n) => { + if let Some(sources) = self.m.locals.get(n) { + stack.extend(sources.iter().copied()); + } + } + Tok::Call(k) => { + let Some(c) = self.m.calls.get(k) else { + continue; + }; + if callee_unknown(c, self.methods) { + out.insert(Root::Unknown); + continue; + } + let Some(s) = self.solved.get(&c.callee) else { + continue; + }; + for r in &s.returns { + match Root::parse(r) { + Some(Root::Param(j)) => { + if let Some(arg) = c.args.get(j) { + stack.extend(arg.iter().copied()); + } + } + // a constructor's receiver is the new object: heap to the caller + Some(Root::Receiver) => match &c.receiver { + Some(recv) => stack.extend(recv.iter().copied()), + None => stack.push(Tok::Heap), + }, + Some(other) => { + out.insert(other); + } + None => {} + } + } + } + } + } + out + } + + fn apply(&mut self, toks: &[Tok], effect: u8, kind: usize) { + for r in self.roots(toks) { + match r { + Root::Param(i) => { + let inert = self.m.params.get(i).is_some_and(|p| p.1); + if !inert { + if let Some(slot) = self.params.get_mut(i) { + *slot = (*slot).max(effect); + } + } + } + Root::Receiver => self.receiver = self.receiver.max(effect), + Root::Heap | Root::Unknown => { + if effect >= BORROW_MUT { + // an object reached from neither a parameter nor the receiver + let level = if r == Root::Unknown || effect == UNKNOWN { + W_UNKNOWN + } else { + W_MAY + }; + if let Some(slot) = self.writes.get_mut(kind) { + *slot = (*slot).max(level); + } + } + } + } + } + } +} + +fn evaluate( + m: &Method, + methods: &HashMap<&str, &Method>, + solved: &HashMap, +) -> Summary { + if !m.unknown.is_empty() { + return Summary { + params: m + .params + .iter() + .map(|(_, inert)| if *inert { PLAIN } else { UNKNOWN }) + .collect(), + receiver: m.receiver.then_some(UNKNOWN), + writes: [W_UNKNOWN; 3], + returns: if m.return_inert { + Vec::new() + } else { + vec!["unknown".to_owned()] + }, + }; + } + let mut e = Eval { + m, + methods, + solved, + params: vec![PLAIN; m.params.len()], + receiver: PLAIN, + writes: [W_NONE; 3], + }; + e.apply(&m.derefs, BORROW, 0); + for (kind, target) in &m.writes { + if *kind == 1 { + if let Some(slot) = e.writes.get_mut(1) { + *slot = (*slot).max(W_MAY); + } + } else { + e.apply(target, BORROW_MUT, *kind); + } + } + e.apply(&m.stores, MAY_ESCAPE, 0); + for c in &m.calls { + if callee_unknown(c, methods) { + for arg in &c.args { + e.apply(arg, UNKNOWN, 0); + } + if let Some(recv) = &c.receiver { + e.apply(recv, UNKNOWN, 0); + } + e.writes = [W_UNKNOWN; 3]; + continue; + } + let Some(s) = solved.get(&c.callee) else { + continue; + }; + for (j, effect) in s.params.iter().enumerate() { + if let Some(arg) = c.args.get(j) { + e.apply(arg, *effect, 0); + } + } + if let (Some(effect), Some(recv)) = (s.receiver, &c.receiver) { + e.apply(recv, effect, 0); + } + for (mine, theirs) in e.writes.iter_mut().zip(s.writes) { + *mine = (*mine).max(theirs); + } + } + let mut returns: BTreeSet = BTreeSet::new(); + if !m.return_inert { + for r in e.roots(&m.returns) { + if let Root::Param(i) = r { + if m.params.get(i).is_some_and(|p| p.1) { + continue; + } + } + returns.insert(r.render()); + } + } + let returns = if returns.contains("unknown") { + vec!["unknown".to_owned()] + } else { + returns.into_iter().collect() + }; + Summary { + params: e.params, + receiver: m.receiver.then_some(e.receiver), + writes: e.writes, + returns, + } +} + +/// Tarjan, iterative, bottom-up — the reference's `_sccs`, frame for frame. +fn sccs(keys: &[String], edges: &BTreeMap>) -> Vec> { + let mut index: HashMap = HashMap::new(); + let mut low: HashMap = HashMap::new(); + let mut on_stack: HashSet = HashSet::new(); + let mut stack: Vec = Vec::new(); + let mut out = Vec::new(); + let mut counter: usize = 0; + let none: Vec = Vec::new(); + for root in keys { + if index.contains_key(root) { + continue; + } + let mut work: Vec<(String, usize)> = vec![(root.clone(), 0)]; + while let Some((v, pos)) = work.pop() { + if pos == 0 { + index.insert(v.clone(), counter); + low.insert(v.clone(), counter); + counter = counter.saturating_add(1); + stack.push(v.clone()); + on_stack.insert(v.clone()); + } + let succ = edges.get(&v).unwrap_or(&none); + if let Some(w) = succ.get(pos) { + work.push((v.clone(), pos.saturating_add(1))); + if !index.contains_key(w) { + work.push((w.clone(), 0)); + } else if on_stack.contains(w) { + let wi = index.get(w).copied().unwrap_or(usize::MAX); + if let Some(lv) = low.get_mut(&v) { + *lv = (*lv).min(wi); + } + } + continue; + } + let lv = low.get(&v).copied().unwrap_or(usize::MAX); + if Some(&lv) == index.get(&v) { + let mut comp = Vec::new(); + while let Some(w) = stack.pop() { + on_stack.remove(&w); + let done = w == v; + comp.push(w); + if done { + break; + } + } + comp.sort(); + out.push(comp); + } + if let Some((parent, _)) = work.last() { + if let Some(lp) = low.get_mut(parent) { + *lp = (*lp).min(lv); + } + } + } + } + out +} + +fn solve(methods: &[Method]) -> HashMap { + let by_key: HashMap<&str, &Method> = methods.iter().map(|m| (m.key.as_str(), m)).collect(); + let mut keys: Vec = methods.iter().map(|m| m.key.clone()).collect(); + keys.sort(); + let mut edges: BTreeMap> = BTreeMap::new(); + for m in methods { + let callees: BTreeSet = m + .calls + .iter() + .filter(|c| !callee_unknown(c, &by_key)) + .map(|c| c.callee.clone()) + .collect(); + edges.insert(m.key.clone(), callees.into_iter().collect()); + } + let mut solved: HashMap = + methods.iter().map(|m| (m.key.clone(), bottom(m))).collect(); + for comp in sccs(&keys, &edges) { + let mut changed = true; + while changed { + changed = false; + for k in &comp { + let Some(m) = by_key.get(k.as_str()) else { + continue; + }; + let new = evaluate(m, &by_key, &solved); + if solved.get(k) != Some(&new) { + solved.insert(k.clone(), new); + changed = true; + } + } + } + } + solved +} + +fn unresolved(m: &Method, methods: &HashMap<&str, &Method>) -> Vec { + let mut reasons: BTreeSet = m.unknown.iter().cloned().collect(); + for c in &m.calls { + if c.dispatch != "direct" { + reasons.insert(format!("{} ({})", c.callee, c.dispatch)); + } else if !methods.contains_key(c.callee.as_str()) { + reasons.insert(format!("{} (no summary)", c.callee)); + } + } + reasons.into_iter().collect() +} + +fn effect_label(level: u8) -> &'static str { + EFFECTS + .get(usize::from(level)) + .copied() + .unwrap_or("unknown") +} + +fn write_label(level: u8) -> &'static str { + WRITES.get(usize::from(level)).copied().unwrap_or("unknown") +} + +/// Sidecar JSON text → the solved document, rendered byte-identically to +/// `python -m ownlang.heap_effects` (`json.dumps(indent=2, sort_keys=True)` +/// plus a newline). +pub(crate) fn dump_heap_effects(text: &str) -> Result { + let doc: Value = serde_json::from_str(text) + .map_err(|_| BridgeError("heap-effect facts: not valid JSON".to_owned()))?; + let methods = load(&doc)?; + let by_key: HashMap<&str, &Method> = methods.iter().map(|m| (m.key.as_str(), m)).collect(); + let solved = solve(&methods); + let mut ordered: Vec<&Method> = methods.iter().collect(); + ordered.sort_by(|a, b| a.key.cmp(&b.key)); + let mut summaries = Vec::with_capacity(ordered.len()); + for m in ordered { + let s = solved.get(&m.key).cloned().unwrap_or_else(|| bottom(m)); + let params: Vec = m + .params + .iter() + .enumerate() + .map(|(i, (name, _))| { + let level = s.params.get(i).copied().unwrap_or(UNKNOWN); + json!({"index": i, "name": name, "effect": effect_label(level)}) + }) + .collect(); + let mut writes = Map::new(); + for (i, kind) in WRITE_KINDS.iter().enumerate() { + let level = s.writes.get(i).copied().unwrap_or(W_UNKNOWN); + writes.insert((*kind).to_owned(), json!(write_label(level))); + } + summaries.push(json!({ + "method": m.key, + "file": m.file, + "line": m.line, + "params": params, + "receiver": s.receiver.map(effect_label), + "writes": writes, + "returns": s.returns, + "unresolved": unresolved(m, &by_key), + })); + } + let out = json!({"heap_effects_version": VERSION, "summaries": summaries}); + let mut text = String::new(); + emit_py(&out, 0, &mut text); + text.push('\n'); + Ok(text) +} diff --git a/rust/crates/own-bridge/src/lib.rs b/rust/crates/own-bridge/src/lib.rs index e7939a87..abc45c46 100644 --- a/rust/crates/own-bridge/src/lib.rs +++ b/rust/crates/own-bridge/src/lib.rs @@ -38,6 +38,7 @@ mod ast; mod dump; +mod heap_effects; mod lower; mod mos; mod render; @@ -96,6 +97,21 @@ pub fn dump_summaries(facts: &OwnIr) -> Result { dump::dump_summaries(facts) } +/// Solve the heap-effect summaries (H0, inert) of one heap-effect SOURCE +/// FACTS document — the extractor's `--heap-effects` sidecar, not `OwnIR`. +/// +/// Byte-identical to `python -m ownlang.heap_effects` over the parity domain +/// (RFC 8259 JSON whose integers fit 64 bits): the solved document as +/// `json.dumps(indent=2, sort_keys=True)` plus a newline. Nothing in the +/// verdict path reads it. +/// +/// # Errors +/// [`BridgeError`] when the text is not JSON or the document violates the +/// sidecar vocabulary; the message text is the reference's, byte-for-byte. +pub fn dump_heap_effects(text: &str) -> Result { + heap_effects::dump_heap_effects(text) +} + /// Run the core over one `OwnIR` facts document and return its findings. /// /// The port of `ownlang/ownir.py::check_facts` at the #259 checkpoint-4 diff --git a/rust/crates/own-bridge/tests/heap_effects.rs b/rust/crates/own-bridge/tests/heap_effects.rs new file mode 100644 index 00000000..0c62d9ac --- /dev/null +++ b/rust/crates/own-bridge/tests/heap_effects.rs @@ -0,0 +1,161 @@ +//! The H0 heap-effect parity harness: for every case of the heap-effect +//! fixture family, +//! +//! ```text +//! .sidecar.json → own_bridge::dump_heap_effects +//! == .summaries.json (byte-exact) +//! or the text of .rejected.txt +//! ``` +//! +//! The goldens are the EXACT stdout bytes of `python -m ownlang.heap_effects` +//! and the exact rejection messages (`tests/test_heap_effects_fixtures.py +//! --write` regenerates them). The ledger is re-derived here, independently +//! of Python: every sidecar is a listed case or rejection, every case has a +//! golden, every rejection has its text — none missing, none orphaned. + +#![allow(clippy::panic, clippy::expect_used)] + +use serde::Deserialize; +use std::collections::BTreeSet; + +const FIXDIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/heap_effects" +); + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + heap_effects_version: i64, + cases: Vec, + rejections: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + name: String, + #[allow(dead_code)] + #[serde(default)] + source: Option, + pins: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Rejection { + name: String, +} + +fn read(name: &str) -> String { + let path = format!("{FIXDIR}/{name}"); + std::fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "cannot read {path}: {e} — regenerate: python tests/test_heap_effects_fixtures.py --write" + ) + }) +} + +fn stems(suffix: &str) -> BTreeSet { + let mut out = BTreeSet::new(); + for entry in std::fs::read_dir(FIXDIR).expect("fixture directory is readable") { + let file = entry.expect("directory entry").file_name(); + let file = file.to_str().expect("fixture filenames are UTF-8"); + if let Some(stem) = file.strip_suffix(suffix) { + out.insert(stem.to_owned()); + } + } + out +} + +/// The same sidecar with `methods[]` reversed (the solve must not care). +fn reversed(text: &str) -> Option { + let mut doc: serde_json::Value = serde_json::from_str(text).ok()?; + doc.get_mut("methods")?.as_array_mut()?.reverse(); + Some(doc.to_string()) +} + +#[test] +fn replays_every_case_and_rejection() { + let manifest: Manifest = + serde_json::from_str(&read("manifest.json")).expect("manifest.json parses (typed, strict)"); + assert_eq!(manifest.heap_effects_version, 1, "manifest version"); + + let cases: BTreeSet = manifest.cases.iter().map(|c| c.name.clone()).collect(); + let rejections: BTreeSet = manifest.rejections.iter().map(|r| r.name.clone()).collect(); + assert_eq!(cases.len(), manifest.cases.len(), "a case is listed twice"); + assert_eq!( + rejections.len(), + manifest.rejections.len(), + "a rejection is listed twice" + ); + assert!( + cases.is_disjoint(&rejections), + "a name is both a case and a rejection" + ); + for c in &manifest.cases { + assert!( + !c.pins.is_empty(), + "case '{}' must say what it pins", + c.name + ); + } + let all: BTreeSet = cases.union(&rejections).cloned().collect(); + assert_eq!(all, stems(".sidecar.json"), "manifest != sidecars on disk"); + assert_eq!( + cases, + stems(".summaries.json"), + "cases != summaries goldens" + ); + assert_eq!( + rejections, + stems(".rejected.txt"), + "rejections != rejection texts" + ); + + for case in &cases { + let text = read(&format!("{case}.sidecar.json")); + let golden = read(&format!("{case}.summaries.json")); + let emitted = own_bridge::dump_heap_effects(&text) + .unwrap_or_else(|e| panic!("{case}: rejected: {e}")); + assert!( + emitted == golden, + "{case}: Rust dump is not byte-identical to the Python golden.\n\ + --- emitted ---\n{emitted}\n--- golden ---\n{golden}" + ); + assert_eq!( + own_bridge::dump_heap_effects(&text).ok().as_deref(), + Some(emitted.as_str()), + "{case}: dump is not deterministic" + ); + if let Some(flipped) = reversed(&text) { + assert_eq!( + own_bridge::dump_heap_effects(&flipped).ok().as_deref(), + Some(emitted.as_str()), + "{case}: dump depends on the order of methods[]" + ); + } + } + for case in &rejections { + let text = read(&format!("{case}.sidecar.json")); + let want = read(&format!("{case}.rejected.txt")); + match own_bridge::dump_heap_effects(&text) { + Ok(_) => panic!("{case}: accepted, but it is a rejection case"), + Err(e) => assert_eq!( + format!("{e}\n"), + want, + "{case}: rejection text differs from the reference" + ), + } + } + assert!( + cases.len() >= 10, + "expected at least 10 cases, got {}", + cases.len() + ); + assert!( + rejections.len() >= 16, + "expected at least 16 rejections, got {}", + rejections.len() + ); +} diff --git a/scripts/heap_effects_gate.py b/scripts/heap_effects_gate.py new file mode 100644 index 00000000..38ed6f6b --- /dev/null +++ b/scripts/heap_effects_gate.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""H0 heap-effect summaries: the real extractor behind the committed sidecar, and inertness. + +`tests/test_heap_effects_fixtures.py` and `own-bridge/tests/heap_effects.rs` replay the +committed sidecar with zero `dotnet`. This script is the producer half (needs `dotnet` on +PATH; zero Python dependencies): + +1. **samples** — `frontend/roslyn/heap-effects-samples/HeapEffects.cs` is run through + the extractor with `--heap-effects`; the sidecar must equal the committed + `tests/fixtures/heap_effects/samples.sidecar.json` (JSON-equal: the extractor writes + platform newlines), and the reference solver must accept it. + +2. **inert** — for every input below the extractor runs twice, without and with + `--heap-effects`: the exit code, stderr and the facts document must be + BYTE-identical. The flag writes a separate file and nothing else; no spelling of it + can move a fact, a verdict or a refusal. Inputs: every state-protocol case (with the + sample API, `--flow-locals`), the protocol refusals (the refusal text must not + move either), the heap-effect samples, and `examples/` as one scan. + +Run: python scripts/heap_effects_gate.py (verify) + python scripts/heap_effects_gate.py --write (regenerate the samples sidecar) +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile + +ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +sys.path.insert(0, ROOT) + +from ownlang.heap_effects import HeapEffectsError, render # noqa: E402 + +EXTRACTOR = os.path.join(ROOT, "frontend", "roslyn", "OwnSharp.Extractor") +SAMPLE_REL = "frontend/roslyn/heap-effects-samples/HeapEffects.cs" +FIXTURE = os.path.join(ROOT, "tests", "fixtures", "heap_effects", "samples.sidecar.json") +PROTO_REL = "frontend/roslyn/protocol-samples" + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, encoding="utf-8", + errors="replace", check=False) + + +def _extractor_dll() -> str: + built = _run(["dotnet", "build", EXTRACTOR, "-c", "Release", "-nologo", "-v", "q"]) + if built.returncode != 0: + print(built.stdout[-2000:]) + raise SystemExit("FAIL: the extractor does not build") + return os.path.join(EXTRACTOR, "bin", "Release", "net8.0", "ownsharp-extract.dll") + + +def samples(dll: str, write: bool, fails: list[str]) -> None: + with tempfile.TemporaryDirectory() as tmp: + sidecar = os.path.join(tmp, "he.json") + done = _run(["dotnet", dll, SAMPLE_REL, "-o", os.path.join(tmp, "facts.json"), + "--heap-effects", sidecar]) + if done.returncode != 0: + fails.append(f"samples: the extractor exited {done.returncode}: {done.stderr[-300:]}") + return + with open(sidecar, encoding="utf-8") as f: + doc = json.load(f) + if write: + with open(FIXTURE, "w", encoding="utf-8", newline="\n") as f: + f.write(json.dumps(doc, indent=2, ensure_ascii=False) + "\n") + else: + with open(FIXTURE, encoding="utf-8") as f: + if json.load(f) != doc: + fails.append("samples: the extractor no longer emits the committed sidecar " + "(tests/fixtures/heap_effects/samples.sidecar.json); run with --write") + try: + render(json.dumps(doc)) + except HeapEffectsError as e: + fails.append(f"samples: the reference rejects the extractor's sidecar: {e}") + + +def _inputs() -> list[tuple[str, list[str]]]: + runs: list[tuple[str, list[str]]] = [] + cases = os.path.join(ROOT, PROTO_REL, "cases") + for name in sorted(os.listdir(cases)): + if name.endswith(".cs"): + runs.append((f"cases/{name}", [f"{PROTO_REL}/Api", f"{PROTO_REL}/cases/{name}", + "--flow-locals"])) + runs.append(("heap-effects-samples", [SAMPLE_REL, "--flow-locals"])) + runs.append(("heap-effects-samples (no flow)", [SAMPLE_REL])) + runs.append(("examples/", ["examples", "--flow-locals"])) + return runs + + +def _refusal_inputs(tmp: str) -> list[tuple[str, list[str]]]: + """The protocol refusals (exit 2): a refusal must be the same refusal with the flag.""" + runs: list[tuple[str, list[str]]] = [] + refused = os.path.join(ROOT, PROTO_REL, "refused") + for name in sorted(os.listdir(refused)): + if name.endswith(".cs.txt"): + staged = os.path.join(tmp, name[: -len(".txt")]) + shutil.copyfile(os.path.join(refused, name), staged) + runs.append((f"refused/{name}", [f"{PROTO_REL}/Api", staged, "--flow-locals"])) + return runs + + +def inert(dll: str, fails: list[str]) -> int: + count = 0 + with tempfile.TemporaryDirectory() as tmp: + for label, args in _inputs() + _refusal_inputs(tmp): + plain_out = os.path.join(tmp, "plain.json") + flag_out = os.path.join(tmp, "flag.json") + sidecar = os.path.join(tmp, "sidecar.json") + for path in (plain_out, flag_out, sidecar): + if os.path.exists(path): + os.remove(path) + plain = _run(["dotnet", dll, *args, "-o", plain_out]) + flag = _run(["dotnet", dll, *args, "-o", flag_out, "--heap-effects", sidecar]) + count += 1 + if (plain.returncode, plain.stderr) != (flag.returncode, flag.stderr): + fails.append(f"inert {label}: exit/stderr moved with --heap-effects " + f"({plain.returncode} -> {flag.returncode})") + continue + if os.path.exists(plain_out) != os.path.exists(flag_out): + fails.append(f"inert {label}: a facts file appeared or vanished with the flag") + continue + if os.path.exists(plain_out): + with open(plain_out, "rb") as a, open(flag_out, "rb") as b: + if a.read() != b.read(): + fails.append(f"inert {label}: the facts document moved with --heap-effects") + with open(sidecar, encoding="utf-8") as f: + try: + render(f.read()) + except HeapEffectsError as e: + fails.append(f"inert {label}: the reference rejects the sidecar: {e}") + return count + + +def main() -> int: + write = "--write" in sys.argv[1:] + dll = _extractor_dll() + fails: list[str] = [] + samples(dll, write, fails) + n = inert(dll, fails) + for f in fails: + print(f"FAIL: {f}") + print(f"heap-effects gate: samples sidecar + {n} inertness runs — " + f"{'FAIL' if fails else 'PASS'}") + return 1 if fails else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/heap_effects/arity_mismatch.rejected.txt b/tests/fixtures/heap_effects/arity_mismatch.rejected.txt new file mode 100644 index 00000000..e25deb72 --- /dev/null +++ b/tests/fixtures/heap_effects/arity_mismatch.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #1: calls[0] has 0 argument slots, its callee takes 1 diff --git a/tests/fixtures/heap_effects/arity_mismatch.sidecar.json b/tests/fixtures/heap_effects/arity_mismatch.sidecar.json new file mode 100644 index 00000000..b30b0259 --- /dev/null +++ b/tests/fixtures/heap_effects/arity_mismatch.sidecar.json @@ -0,0 +1,50 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "A(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "B()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "A(Order)", + "dispatch": "direct", + "receiver": null, + "args": [], + "line": 1 + } + ], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/bad_dispatch.rejected.txt b/tests/fixtures/heap_effects/bad_dispatch.rejected.txt new file mode 100644 index 00000000..fcacca7b --- /dev/null +++ b/tests/fixtures/heap_effects/bad_dispatch.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #0: calls[0] has an unknown dispatch diff --git a/tests/fixtures/heap_effects/bad_dispatch.sidecar.json b/tests/fixtures/heap_effects/bad_dispatch.sidecar.json new file mode 100644 index 00000000..20260cf1 --- /dev/null +++ b/tests/fixtures/heap_effects/bad_dispatch.sidecar.json @@ -0,0 +1,29 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "A()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "X()", + "dispatch": "inline", + "receiver": null, + "args": [], + "line": 1 + } + ], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/bad_version.rejected.txt b/tests/fixtures/heap_effects/bad_version.rejected.txt new file mode 100644 index 00000000..66019457 --- /dev/null +++ b/tests/fixtures/heap_effects/bad_version.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: heap_effects_version must be 1 diff --git a/tests/fixtures/heap_effects/bad_version.sidecar.json b/tests/fixtures/heap_effects/bad_version.sidecar.json new file mode 100644 index 00000000..c7366414 --- /dev/null +++ b/tests/fixtures/heap_effects/bad_version.sidecar.json @@ -0,0 +1,4 @@ +{ + "heap_effects_version": 2, + "methods": [] +} diff --git a/tests/fixtures/heap_effects/bad_write_kind.rejected.txt b/tests/fixtures/heap_effects/bad_write_kind.rejected.txt new file mode 100644 index 00000000..6219e38f --- /dev/null +++ b/tests/fixtures/heap_effects/bad_write_kind.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #0: writes[0] has an unknown kind diff --git a/tests/fixtures/heap_effects/bad_write_kind.sidecar.json b/tests/fixtures/heap_effects/bad_write_kind.sidecar.json new file mode 100644 index 00000000..35873357 --- /dev/null +++ b/tests/fixtures/heap_effects/bad_write_kind.sidecar.json @@ -0,0 +1,25 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "A()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "global", + "target": [] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/bool_line.rejected.txt b/tests/fixtures/heap_effects/bool_line.rejected.txt new file mode 100644 index 00000000..6f082802 --- /dev/null +++ b/tests/fixtures/heap_effects/bool_line.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #0: field 'line' is missing or has the wrong type diff --git a/tests/fixtures/heap_effects/bool_line.sidecar.json b/tests/fixtures/heap_effects/bool_line.sidecar.json new file mode 100644 index 00000000..9d162d38 --- /dev/null +++ b/tests/fixtures/heap_effects/bool_line.sidecar.json @@ -0,0 +1,20 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "A()", + "file": "synthetic.cs", + "line": true, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/bool_version.rejected.txt b/tests/fixtures/heap_effects/bool_version.rejected.txt new file mode 100644 index 00000000..66019457 --- /dev/null +++ b/tests/fixtures/heap_effects/bool_version.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: heap_effects_version must be 1 diff --git a/tests/fixtures/heap_effects/bool_version.sidecar.json b/tests/fixtures/heap_effects/bool_version.sidecar.json new file mode 100644 index 00000000..2dc853d7 --- /dev/null +++ b/tests/fixtures/heap_effects/bool_version.sidecar.json @@ -0,0 +1,4 @@ +{ + "heap_effects_version": true, + "methods": [] +} diff --git a/tests/fixtures/heap_effects/constructor_result.sidecar.json b/tests/fixtures/heap_effects/constructor_result.sidecar.json new file mode 100644 index 00000000..f15a0152 --- /dev/null +++ b/tests/fixtures/heap_effects/constructor_result.sidecar.json @@ -0,0 +1,127 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "W..ctor(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": true, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "receiver" + ] + } + ], + "stores": [ + "param:0" + ], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "W.Self()", + "file": "synthetic.cs", + "line": 1, + "receiver": true, + "return_inert": false, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [ + "receiver" + ], + "calls": [], + "unknown": [] + }, + { + "key": "Make(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": false, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [ + "heap" + ], + "calls": [ + { + "id": 0, + "callee": "W..ctor(Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ] + ], + "line": 1 + } + ], + "unknown": [] + }, + { + "key": "Use()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [ + { + "id": 0, + "name": "w", + "sources": [ + "heap" + ] + } + ], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "call:0" + ] + } + ], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "W.Self()", + "dispatch": "direct", + "receiver": null, + "args": [], + "line": 1 + } + ], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/constructor_result.summaries.json b/tests/fixtures/heap_effects/constructor_result.summaries.json new file mode 100644 index 00000000..b8f30ef3 --- /dev/null +++ b/tests/fixtures/heap_effects/constructor_result.summaries.json @@ -0,0 +1,77 @@ +{ + "heap_effects_version": 1, + "summaries": [ + { + "file": "synthetic.cs", + "line": 1, + "method": "Make(Order)", + "params": [ + { + "effect": "may_escape", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [ + "heap" + ], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "Use()", + "params": [], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "may", + "static": "none" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "W..ctor(Order)", + "params": [ + { + "effect": "may_escape", + "index": 0, + "name": "o" + } + ], + "receiver": "borrow_mut", + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "W.Self()", + "params": [], + "receiver": "plain", + "returns": [ + "receiver" + ], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + } + ] +} diff --git a/tests/fixtures/heap_effects/dispatch_kinds.sidecar.json b/tests/fixtures/heap_effects/dispatch_kinds.sidecar.json new file mode 100644 index 00000000..d96b6c00 --- /dev/null +++ b/tests/fixtures/heap_effects/dispatch_kinds.sidecar.json @@ -0,0 +1,73 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "Calls(Order, Order, Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "a", + "inert": false + }, + { + "index": 1, + "name": "b", + "inert": false + }, + { + "index": 2, + "name": "c", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "I.M()", + "dispatch": "virtual", + "receiver": [ + "param:0" + ], + "args": [], + "line": 1 + }, + { + "id": 1, + "callee": "D.Invoke(Order)", + "dispatch": "delegate", + "receiver": [ + "heap" + ], + "args": [ + [ + "param:1" + ] + ], + "line": 1 + }, + { + "id": 2, + "callee": "Ext.Read(Order)", + "dispatch": "extern", + "receiver": null, + "args": [ + [ + "param:2" + ] + ], + "line": 1 + } + ], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/dispatch_kinds.summaries.json b/tests/fixtures/heap_effects/dispatch_kinds.summaries.json new file mode 100644 index 00000000..87450644 --- /dev/null +++ b/tests/fixtures/heap_effects/dispatch_kinds.summaries.json @@ -0,0 +1,39 @@ +{ + "heap_effects_version": 1, + "summaries": [ + { + "file": "synthetic.cs", + "line": 1, + "method": "Calls(Order, Order, Order)", + "params": [ + { + "effect": "unknown", + "index": 0, + "name": "a" + }, + { + "effect": "unknown", + "index": 1, + "name": "b" + }, + { + "effect": "unknown", + "index": 2, + "name": "c" + } + ], + "receiver": null, + "returns": [], + "unresolved": [ + "D.Invoke(Order) (delegate)", + "Ext.Read(Order) (extern)", + "I.M() (virtual)" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + } + ] +} diff --git a/tests/fixtures/heap_effects/duplicate_key.rejected.txt b/tests/fixtures/heap_effects/duplicate_key.rejected.txt new file mode 100644 index 00000000..e2453e11 --- /dev/null +++ b/tests/fixtures/heap_effects/duplicate_key.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #1: duplicate key diff --git a/tests/fixtures/heap_effects/duplicate_key.sidecar.json b/tests/fixtures/heap_effects/duplicate_key.sidecar.json new file mode 100644 index 00000000..8a0c3802 --- /dev/null +++ b/tests/fixtures/heap_effects/duplicate_key.sidecar.json @@ -0,0 +1,35 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "A()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "A()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/heap_through_callee.sidecar.json b/tests/fixtures/heap_effects/heap_through_callee.sidecar.json new file mode 100644 index 00000000..77361571 --- /dev/null +++ b/tests/fixtures/heap_effects/heap_through_callee.sidecar.json @@ -0,0 +1,109 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "Keep(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "static", + "target": [] + } + ], + "stores": [ + "param:0" + ], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Mut(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "param:0" + ] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "FromStatic()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [ + { + "id": 0, + "name": "s", + "sources": [ + "heap" + ] + } + ], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Keep(Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "local:0" + ] + ], + "line": 1 + }, + { + "id": 1, + "callee": "Mut(Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "local:0" + ] + ], + "line": 1 + } + ], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/heap_through_callee.summaries.json b/tests/fixtures/heap_effects/heap_through_callee.summaries.json new file mode 100644 index 00000000..a7761cd0 --- /dev/null +++ b/tests/fixtures/heap_effects/heap_through_callee.summaries.json @@ -0,0 +1,59 @@ +{ + "heap_effects_version": 1, + "summaries": [ + { + "file": "synthetic.cs", + "line": 1, + "method": "FromStatic()", + "params": [], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "may", + "static": "may" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "Keep(Order)", + "params": [ + { + "effect": "may_escape", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "may" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "Mut(Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + } + ] +} diff --git a/tests/fixtures/heap_effects/huge_index.rejected.txt b/tests/fixtures/heap_effects/huge_index.rejected.txt new file mode 100644 index 00000000..4840620b --- /dev/null +++ b/tests/fixtures/heap_effects/huge_index.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #0: field 'params' is missing or has the wrong type diff --git a/tests/fixtures/heap_effects/huge_index.sidecar.json b/tests/fixtures/heap_effects/huge_index.sidecar.json new file mode 100644 index 00000000..6c916dc4 --- /dev/null +++ b/tests/fixtures/heap_effects/huge_index.sidecar.json @@ -0,0 +1,26 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "B(int)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 1180591620717411303424, + "name": "n", + "inert": true + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/local_cycle.sidecar.json b/tests/fixtures/heap_effects/local_cycle.sidecar.json new file mode 100644 index 00000000..6f4ecc3b --- /dev/null +++ b/tests/fixtures/heap_effects/local_cycle.sidecar.json @@ -0,0 +1,49 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "Cycle(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [ + { + "id": 0, + "name": "a", + "sources": [ + "local:1", + "param:0" + ] + }, + { + "id": 1, + "name": "b", + "sources": [ + "local:0" + ] + } + ], + "derefs": [], + "writes": [ + { + "kind": "indirect", + "target": [ + "local:1" + ] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/local_cycle.summaries.json b/tests/fixtures/heap_effects/local_cycle.summaries.json new file mode 100644 index 00000000..63d14feb --- /dev/null +++ b/tests/fixtures/heap_effects/local_cycle.summaries.json @@ -0,0 +1,25 @@ +{ + "heap_effects_version": 1, + "summaries": [ + { + "file": "synthetic.cs", + "line": 1, + "method": "Cycle(Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + } + ] +} diff --git a/tests/fixtures/heap_effects/manifest.json b/tests/fixtures/heap_effects/manifest.json new file mode 100644 index 00000000..e476ebab --- /dev/null +++ b/tests/fixtures/heap_effects/manifest.json @@ -0,0 +1,96 @@ +{ + "heap_effects_version": 1, + "cases": [ + { + "name": "samples", + "source": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "pins": "the five kill fixtures, transitive A->B, SCC (pure, mutating, unknown), receiver, local alias, reassigned parameter, return alias, static initialization, lambda, virtual" + }, + { + "name": "missing_callee", + "pins": "a direct call with no record is Unknown, logged '(no summary)'" + }, + { + "name": "constructor_result", + "pins": "a returned receiver with no receiver at the call (a constructor) is heap; a kept constructor argument escapes" + }, + { + "name": "unknown_root_write", + "pins": "a write through a value an unknown call returned is an unknown write" + }, + { + "name": "local_cycle", + "pins": "local sources resolve through cycles; an indirect write through a local is the parameter's borrow_mut" + }, + { + "name": "unknown_keeps_inert_plain", + "pins": "an unmodelled construct is Unknown everywhere except inert by-value parameters" + }, + { + "name": "scc_return_alias", + "pins": "return aliases converge around a three-method cycle; a write through the result maps back to the argument" + }, + { + "name": "heap_through_callee", + "pins": "a callee's borrow_mut / may_escape on a heap argument is an instance write of the caller" + }, + { + "name": "dispatch_kinds", + "pins": "virtual, delegate and extern dispatch are Unknown on the receiver and every argument" + }, + { + "name": "receiver_mapping", + "pins": "a callee's receiver effect lands on the caller's receiver argument" + } + ], + "rejections": [ + { + "name": "not_object" + }, + { + "name": "bad_version" + }, + { + "name": "bool_version" + }, + { + "name": "methods_not_array" + }, + { + "name": "duplicate_key" + }, + { + "name": "bool_line" + }, + { + "name": "huge_index" + }, + { + "name": "params_out_of_sequence" + }, + { + "name": "bad_dispatch" + }, + { + "name": "bad_write_kind" + }, + { + "name": "token_out_of_range" + }, + { + "name": "receiver_on_static" + }, + { + "name": "non_canonical_token" + }, + { + "name": "arity_mismatch" + }, + { + "name": "missing_field" + }, + { + "name": "not_json" + } + ] +} diff --git a/tests/fixtures/heap_effects/methods_not_array.rejected.txt b/tests/fixtures/heap_effects/methods_not_array.rejected.txt new file mode 100644 index 00000000..e95cc124 --- /dev/null +++ b/tests/fixtures/heap_effects/methods_not_array.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: methods must be an array of objects diff --git a/tests/fixtures/heap_effects/methods_not_array.sidecar.json b/tests/fixtures/heap_effects/methods_not_array.sidecar.json new file mode 100644 index 00000000..d8523e53 --- /dev/null +++ b/tests/fixtures/heap_effects/methods_not_array.sidecar.json @@ -0,0 +1,4 @@ +{ + "heap_effects_version": 1, + "methods": {} +} diff --git a/tests/fixtures/heap_effects/missing_callee.sidecar.json b/tests/fixtures/heap_effects/missing_callee.sidecar.json new file mode 100644 index 00000000..5d807941 --- /dev/null +++ b/tests/fixtures/heap_effects/missing_callee.sidecar.json @@ -0,0 +1,39 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "Caller(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Gone(Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ] + ], + "line": 1 + } + ], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/missing_callee.summaries.json b/tests/fixtures/heap_effects/missing_callee.summaries.json new file mode 100644 index 00000000..8f5e8845 --- /dev/null +++ b/tests/fixtures/heap_effects/missing_callee.summaries.json @@ -0,0 +1,27 @@ +{ + "heap_effects_version": 1, + "summaries": [ + { + "file": "synthetic.cs", + "line": 1, + "method": "Caller(Order)", + "params": [ + { + "effect": "unknown", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [ + "Gone(Order) (no summary)" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + } + ] +} diff --git a/tests/fixtures/heap_effects/missing_field.rejected.txt b/tests/fixtures/heap_effects/missing_field.rejected.txt new file mode 100644 index 00000000..e474cd49 --- /dev/null +++ b/tests/fixtures/heap_effects/missing_field.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #0: field 'unknown' is missing or has the wrong type diff --git a/tests/fixtures/heap_effects/missing_field.sidecar.json b/tests/fixtures/heap_effects/missing_field.sidecar.json new file mode 100644 index 00000000..41323a25 --- /dev/null +++ b/tests/fixtures/heap_effects/missing_field.sidecar.json @@ -0,0 +1,19 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "A()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/non_canonical_token.rejected.txt b/tests/fixtures/heap_effects/non_canonical_token.rejected.txt new file mode 100644 index 00000000..5a2a8f04 --- /dev/null +++ b/tests/fixtures/heap_effects/non_canonical_token.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #0: derefs holds a token outside the vocabulary diff --git a/tests/fixtures/heap_effects/non_canonical_token.sidecar.json b/tests/fixtures/heap_effects/non_canonical_token.sidecar.json new file mode 100644 index 00000000..8c498e19 --- /dev/null +++ b/tests/fixtures/heap_effects/non_canonical_token.sidecar.json @@ -0,0 +1,28 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "A(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [ + "param:01" + ], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/not_json.rejected.txt b/tests/fixtures/heap_effects/not_json.rejected.txt new file mode 100644 index 00000000..e047d5d1 --- /dev/null +++ b/tests/fixtures/heap_effects/not_json.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: not valid JSON diff --git a/tests/fixtures/heap_effects/not_json.sidecar.json b/tests/fixtures/heap_effects/not_json.sidecar.json new file mode 100644 index 00000000..a7d3cff3 --- /dev/null +++ b/tests/fixtures/heap_effects/not_json.sidecar.json @@ -0,0 +1 @@ +{"heap_effects_version": 1, "methods": [NaN]} diff --git a/tests/fixtures/heap_effects/not_object.rejected.txt b/tests/fixtures/heap_effects/not_object.rejected.txt new file mode 100644 index 00000000..383f49d2 --- /dev/null +++ b/tests/fixtures/heap_effects/not_object.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: the document is not an object diff --git a/tests/fixtures/heap_effects/not_object.sidecar.json b/tests/fixtures/heap_effects/not_object.sidecar.json new file mode 100644 index 00000000..192c2445 --- /dev/null +++ b/tests/fixtures/heap_effects/not_object.sidecar.json @@ -0,0 +1,4 @@ +[ + 1, + 2 +] diff --git a/tests/fixtures/heap_effects/params_out_of_sequence.rejected.txt b/tests/fixtures/heap_effects/params_out_of_sequence.rejected.txt new file mode 100644 index 00000000..60e25f82 --- /dev/null +++ b/tests/fixtures/heap_effects/params_out_of_sequence.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #0: params[0] is out of sequence diff --git a/tests/fixtures/heap_effects/params_out_of_sequence.sidecar.json b/tests/fixtures/heap_effects/params_out_of_sequence.sidecar.json new file mode 100644 index 00000000..0158899b --- /dev/null +++ b/tests/fixtures/heap_effects/params_out_of_sequence.sidecar.json @@ -0,0 +1,26 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "A()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 1, + "name": "n", + "inert": true + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/receiver_mapping.sidecar.json b/tests/fixtures/heap_effects/receiver_mapping.sidecar.json new file mode 100644 index 00000000..1d84ed8c --- /dev/null +++ b/tests/fixtures/heap_effects/receiver_mapping.sidecar.json @@ -0,0 +1,127 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "C.Set()", + "file": "synthetic.cs", + "line": 1, + "receiver": true, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "receiver" + ] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "C.Get()", + "file": "synthetic.cs", + "line": 1, + "receiver": true, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [ + "receiver" + ], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "C.Both()", + "file": "synthetic.cs", + "line": 1, + "receiver": true, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "C.Set()", + "dispatch": "direct", + "receiver": [ + "receiver" + ], + "args": [], + "line": 1 + }, + { + "id": 1, + "callee": "C.Get()", + "dispatch": "direct", + "receiver": [ + "receiver" + ], + "args": [], + "line": 1 + } + ], + "unknown": [] + }, + { + "key": "Drive(C, C)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "x", + "inert": false + }, + { + "index": 1, + "name": "y", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "C.Set()", + "dispatch": "direct", + "receiver": [ + "param:0" + ], + "args": [], + "line": 1 + }, + { + "id": 1, + "callee": "C.Get()", + "dispatch": "direct", + "receiver": [ + "param:1" + ], + "args": [], + "line": 1 + } + ], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/receiver_mapping.summaries.json b/tests/fixtures/heap_effects/receiver_mapping.summaries.json new file mode 100644 index 00000000..93158698 --- /dev/null +++ b/tests/fixtures/heap_effects/receiver_mapping.summaries.json @@ -0,0 +1,72 @@ +{ + "heap_effects_version": 1, + "summaries": [ + { + "file": "synthetic.cs", + "line": 1, + "method": "C.Both()", + "params": [], + "receiver": "borrow_mut", + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "C.Get()", + "params": [], + "receiver": "borrow", + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "C.Set()", + "params": [], + "receiver": "borrow_mut", + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "Drive(C, C)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "x" + }, + { + "effect": "borrow", + "index": 1, + "name": "y" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + } + ] +} diff --git a/tests/fixtures/heap_effects/receiver_on_static.rejected.txt b/tests/fixtures/heap_effects/receiver_on_static.rejected.txt new file mode 100644 index 00000000..63693234 --- /dev/null +++ b/tests/fixtures/heap_effects/receiver_on_static.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #0: stores holds a token outside the vocabulary diff --git a/tests/fixtures/heap_effects/receiver_on_static.sidecar.json b/tests/fixtures/heap_effects/receiver_on_static.sidecar.json new file mode 100644 index 00000000..12bd565b --- /dev/null +++ b/tests/fixtures/heap_effects/receiver_on_static.sidecar.json @@ -0,0 +1,22 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "A()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [ + "receiver" + ], + "returns": [], + "calls": [], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/samples.sidecar.json b/tests/fixtures/heap_effects/samples.sidecar.json new file mode 100644 index 00000000..ac3d1089 --- /dev/null +++ b/tests/fixtures/heap_effects/samples.sidecar.json @@ -0,0 +1,1041 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "Own.HeapEffects.Samples.Holder.Touch()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 151, + "receiver": true, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [ + "captured primary-constructor parameter" + ] + }, + { + "key": "Own.HeapEffects.Samples.Kill.Escapes(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 38, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "x", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "static", + "target": [] + } + ], + "stores": [ + "param:0" + ], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Kill.Logs()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 44, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "System.Console.WriteLine(string?)", + "dispatch": "extern", + "receiver": null, + "args": [ + [] + ], + "line": 44 + } + ], + "unknown": [ + "member of external type System.Console" + ] + }, + { + "key": "Own.HeapEffects.Samples.Kill.Mutates(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 35, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "x", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "param:0" + ] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Kill.TouchesGlobalState()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 41, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "static", + "target": [] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Kill.Twice(int)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 32, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "x", + "inert": true + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Lazy.Lazy()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 142, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "static", + "target": [] + } + ], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "System.Environment.ProcessorCount.get", + "dispatch": "extern", + "receiver": null, + "args": [], + "line": 142 + } + ], + "unknown": [ + "member of external type System.Environment", + "static initialization of Own.HeapEffects.Samples.Lazy" + ] + }, + { + "key": "Own.HeapEffects.Samples.Lazy.Value()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 144, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [ + "static initialization of Own.HeapEffects.Samples.Lazy" + ] + }, + { + "key": "Own.HeapEffects.Samples.Opaque.CallsLazy()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 135, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Lazy.Value()", + "dispatch": "direct", + "receiver": null, + "args": [], + "line": 135 + } + ], + "unknown": [ + "static initialization of Own.HeapEffects.Samples.Lazy" + ] + }, + { + "key": "Own.HeapEffects.Samples.Opaque.Lambda(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 132, + "receiver": false, + "return_inert": false, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [ + "expression DelegateCreation" + ] + }, + { + "key": "Own.HeapEffects.Samples.Opaque.Virtual(Own.HeapEffects.Samples.IShape)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 129, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "s", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.IShape.Area()", + "dispatch": "virtual", + "receiver": [ + "param:0" + ], + "args": [], + "line": 129 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Order.Peek()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 18, + "receiver": true, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [ + "receiver" + ], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Order.Touch()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 17, + "receiver": true, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "receiver" + ] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Recursive.Even(int)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 96, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "n", + "inert": true + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Recursive.Odd(int)", + "dispatch": "direct", + "receiver": null, + "args": [ + [] + ], + "line": 96 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Recursive.Odd(int)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 97, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "n", + "inert": true + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Recursive.Even(int)", + "dispatch": "direct", + "receiver": null, + "args": [ + [] + ], + "line": 97 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Recursive.Ping(Own.HeapEffects.Samples.Order, int)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 100, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + }, + { + "index": 1, + "name": "n", + "inert": true + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Recursive.Pong(Own.HeapEffects.Samples.Order, int)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ], + [] + ], + "line": 103 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Recursive.Pong(Own.HeapEffects.Samples.Order, int)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 106, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + }, + { + "index": 1, + "name": "n", + "inert": true + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "param:0" + ] + } + ], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Recursive.Ping(Own.HeapEffects.Samples.Order, int)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ], + [] + ], + "line": 109 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Recursive.Tick(Own.HeapEffects.Samples.Order, int)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 113, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + }, + { + "index": 1, + "name": "n", + "inert": true + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Recursive.Tock(Own.HeapEffects.Samples.Order, int)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ], + [] + ], + "line": 116 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Recursive.Tock(Own.HeapEffects.Samples.Order, int)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 119, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + }, + { + "index": 1, + "name": "n", + "inert": true + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "System.Console.WriteLine(int)", + "dispatch": "extern", + "receiver": null, + "args": [ + [] + ], + "line": 121 + }, + { + "id": 1, + "callee": "Own.HeapEffects.Samples.Recursive.Tick(Own.HeapEffects.Samples.Order, int)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ], + [] + ], + "line": 122 + } + ], + "unknown": [ + "member of external type System.Console" + ] + }, + { + "key": "Own.HeapEffects.Samples.Returns.Fresh()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 89, + "receiver": false, + "return_inert": false, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [ + "heap" + ], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Returns.FromHeap()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 88, + "receiver": false, + "return_inert": false, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [ + "heap" + ], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Returns.Id(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 86, + "receiver": false, + "return_inert": false, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [ + "param:0" + ], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Returns.MutatesResult(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 90, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "call:0" + ] + } + ], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Returns.Id(Own.HeapEffects.Samples.Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ] + ], + "line": 90 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Returns.ViaCall(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 87, + "receiver": false, + "return_inert": false, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [ + "call:0" + ], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Returns.Id(Own.HeapEffects.Samples.Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ] + ], + "line": 87 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.A(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 50, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "x", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Transitive.B(Own.HeapEffects.Samples.Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ] + ], + "line": 50 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.AliasMutates(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 64, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [ + { + "id": 0, + "name": "a", + "sources": [ + "param:0" + ] + } + ], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "local:0" + ] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.B(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 51, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "x", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "param:0" + ] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.CallsLogs()", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 57, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Kill.Logs()", + "dispatch": "direct", + "receiver": null, + "args": [], + "line": 57 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.CallsPeek(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 61, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Order.Peek()", + "dispatch": "direct", + "receiver": [ + "param:0" + ], + "args": [], + "line": 61 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.CallsTouch(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 60, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Order.Touch()", + "dispatch": "direct", + "receiver": [ + "param:0" + ], + "args": [], + "line": 60 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.EscapeInner(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 54, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "x", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "static", + "target": [] + } + ], + "stores": [ + "param:0" + ], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.EscapeOuter(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 53, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "x", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Own.HeapEffects.Samples.Transitive.EscapeInner(Own.HeapEffects.Samples.Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ] + ], + "line": 53 + } + ], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.ReadsDeep(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 81, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [ + "param:0" + ], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.Reassigned(Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 71, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [ + { + "id": 0, + "name": "o", + "sources": [ + "heap", + "param:0" + ] + } + ], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "local:0" + ] + } + ], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + }, + { + "key": "Own.HeapEffects.Samples.Transitive.SetFirst(Own.HeapEffects.Samples.Order[], Own.HeapEffects.Samples.Order)", + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 78, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "items", + "inert": false + }, + { + "index": 1, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "indirect", + "target": [ + "param:0" + ] + } + ], + "stores": [ + "param:1" + ], + "returns": [], + "calls": [], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/samples.summaries.json b/tests/fixtures/heap_effects/samples.summaries.json new file mode 100644 index 00000000..97f35667 --- /dev/null +++ b/tests/fixtures/heap_effects/samples.summaries.json @@ -0,0 +1,694 @@ +{ + "heap_effects_version": 1, + "summaries": [ + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 151, + "method": "Own.HeapEffects.Samples.Holder.Touch()", + "params": [], + "receiver": "unknown", + "returns": [], + "unresolved": [ + "captured primary-constructor parameter" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 38, + "method": "Own.HeapEffects.Samples.Kill.Escapes(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "may_escape", + "index": 0, + "name": "x" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "may" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 44, + "method": "Own.HeapEffects.Samples.Kill.Logs()", + "params": [], + "receiver": null, + "returns": [], + "unresolved": [ + "System.Console.WriteLine(string?) (extern)", + "member of external type System.Console" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 35, + "method": "Own.HeapEffects.Samples.Kill.Mutates(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "x" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 41, + "method": "Own.HeapEffects.Samples.Kill.TouchesGlobalState()", + "params": [], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "may" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 32, + "method": "Own.HeapEffects.Samples.Kill.Twice(int)", + "params": [ + { + "effect": "plain", + "index": 0, + "name": "x" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 142, + "method": "Own.HeapEffects.Samples.Lazy.Lazy()", + "params": [], + "receiver": null, + "returns": [], + "unresolved": [ + "System.Environment.ProcessorCount.get (extern)", + "member of external type System.Environment", + "static initialization of Own.HeapEffects.Samples.Lazy" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 144, + "method": "Own.HeapEffects.Samples.Lazy.Value()", + "params": [], + "receiver": null, + "returns": [], + "unresolved": [ + "static initialization of Own.HeapEffects.Samples.Lazy" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 135, + "method": "Own.HeapEffects.Samples.Opaque.CallsLazy()", + "params": [], + "receiver": null, + "returns": [], + "unresolved": [ + "static initialization of Own.HeapEffects.Samples.Lazy" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 132, + "method": "Own.HeapEffects.Samples.Opaque.Lambda(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "unknown", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [ + "unknown" + ], + "unresolved": [ + "expression DelegateCreation" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 129, + "method": "Own.HeapEffects.Samples.Opaque.Virtual(Own.HeapEffects.Samples.IShape)", + "params": [ + { + "effect": "unknown", + "index": 0, + "name": "s" + } + ], + "receiver": null, + "returns": [], + "unresolved": [ + "Own.HeapEffects.Samples.IShape.Area() (virtual)" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 18, + "method": "Own.HeapEffects.Samples.Order.Peek()", + "params": [], + "receiver": "borrow", + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 17, + "method": "Own.HeapEffects.Samples.Order.Touch()", + "params": [], + "receiver": "borrow_mut", + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 96, + "method": "Own.HeapEffects.Samples.Recursive.Even(int)", + "params": [ + { + "effect": "plain", + "index": 0, + "name": "n" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 97, + "method": "Own.HeapEffects.Samples.Recursive.Odd(int)", + "params": [ + { + "effect": "plain", + "index": 0, + "name": "n" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 100, + "method": "Own.HeapEffects.Samples.Recursive.Ping(Own.HeapEffects.Samples.Order, int)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "o" + }, + { + "effect": "plain", + "index": 1, + "name": "n" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 106, + "method": "Own.HeapEffects.Samples.Recursive.Pong(Own.HeapEffects.Samples.Order, int)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "o" + }, + { + "effect": "plain", + "index": 1, + "name": "n" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 113, + "method": "Own.HeapEffects.Samples.Recursive.Tick(Own.HeapEffects.Samples.Order, int)", + "params": [ + { + "effect": "unknown", + "index": 0, + "name": "o" + }, + { + "effect": "plain", + "index": 1, + "name": "n" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 119, + "method": "Own.HeapEffects.Samples.Recursive.Tock(Own.HeapEffects.Samples.Order, int)", + "params": [ + { + "effect": "unknown", + "index": 0, + "name": "o" + }, + { + "effect": "plain", + "index": 1, + "name": "n" + } + ], + "receiver": null, + "returns": [], + "unresolved": [ + "System.Console.WriteLine(int) (extern)", + "member of external type System.Console" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 89, + "method": "Own.HeapEffects.Samples.Returns.Fresh()", + "params": [], + "receiver": null, + "returns": [ + "heap" + ], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 88, + "method": "Own.HeapEffects.Samples.Returns.FromHeap()", + "params": [], + "receiver": null, + "returns": [ + "heap" + ], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 86, + "method": "Own.HeapEffects.Samples.Returns.Id(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "plain", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [ + "param:0" + ], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 90, + "method": "Own.HeapEffects.Samples.Returns.MutatesResult(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 87, + "method": "Own.HeapEffects.Samples.Returns.ViaCall(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "plain", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [ + "param:0" + ], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 50, + "method": "Own.HeapEffects.Samples.Transitive.A(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "x" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 64, + "method": "Own.HeapEffects.Samples.Transitive.AliasMutates(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 51, + "method": "Own.HeapEffects.Samples.Transitive.B(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "x" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 57, + "method": "Own.HeapEffects.Samples.Transitive.CallsLogs()", + "params": [], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 61, + "method": "Own.HeapEffects.Samples.Transitive.CallsPeek(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "borrow", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 60, + "method": "Own.HeapEffects.Samples.Transitive.CallsTouch(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 54, + "method": "Own.HeapEffects.Samples.Transitive.EscapeInner(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "may_escape", + "index": 0, + "name": "x" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "may" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 53, + "method": "Own.HeapEffects.Samples.Transitive.EscapeOuter(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "may_escape", + "index": 0, + "name": "x" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "may" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 81, + "method": "Own.HeapEffects.Samples.Transitive.ReadsDeep(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "borrow", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 71, + "method": "Own.HeapEffects.Samples.Transitive.Reassigned(Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "may", + "static": "none" + } + }, + { + "file": "frontend/roslyn/heap-effects-samples/HeapEffects.cs", + "line": 78, + "method": "Own.HeapEffects.Samples.Transitive.SetFirst(Own.HeapEffects.Samples.Order[], Own.HeapEffects.Samples.Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "items" + }, + { + "effect": "may_escape", + "index": 1, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + } + ] +} diff --git a/tests/fixtures/heap_effects/scc_return_alias.sidecar.json b/tests/fixtures/heap_effects/scc_return_alias.sidecar.json new file mode 100644 index 00000000..1b6d8117 --- /dev/null +++ b/tests/fixtures/heap_effects/scc_return_alias.sidecar.json @@ -0,0 +1,155 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "R(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": false, + "params": [ + { + "index": 0, + "name": "x", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [ + "call:0" + ], + "calls": [ + { + "id": 0, + "callee": "S(Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ] + ], + "line": 1 + } + ], + "unknown": [] + }, + { + "key": "S(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": false, + "params": [ + { + "index": 0, + "name": "x", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [ + "call:0" + ], + "calls": [ + { + "id": 0, + "callee": "T(Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ] + ], + "line": 1 + } + ], + "unknown": [] + }, + { + "key": "T(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": false, + "params": [ + { + "index": 0, + "name": "x", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [ + "param:0", + "call:0" + ], + "calls": [ + { + "id": 0, + "callee": "R(Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ] + ], + "line": 1 + } + ], + "unknown": [] + }, + { + "key": "U(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "y", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "call:0" + ] + } + ], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "R(Order)", + "dispatch": "direct", + "receiver": null, + "args": [ + [ + "param:0" + ] + ], + "line": 1 + } + ], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/scc_return_alias.summaries.json b/tests/fixtures/heap_effects/scc_return_alias.summaries.json new file mode 100644 index 00000000..a5d49455 --- /dev/null +++ b/tests/fixtures/heap_effects/scc_return_alias.summaries.json @@ -0,0 +1,91 @@ +{ + "heap_effects_version": 1, + "summaries": [ + { + "file": "synthetic.cs", + "line": 1, + "method": "R(Order)", + "params": [ + { + "effect": "plain", + "index": 0, + "name": "x" + } + ], + "receiver": null, + "returns": [ + "param:0" + ], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "S(Order)", + "params": [ + { + "effect": "plain", + "index": 0, + "name": "x" + } + ], + "receiver": null, + "returns": [ + "param:0" + ], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "T(Order)", + "params": [ + { + "effect": "plain", + "index": 0, + "name": "x" + } + ], + "receiver": null, + "returns": [ + "param:0" + ], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "U(Order)", + "params": [ + { + "effect": "borrow_mut", + "index": 0, + "name": "y" + } + ], + "receiver": null, + "returns": [], + "unresolved": [], + "writes": { + "indirect": "none", + "instance": "none", + "static": "none" + } + } + ] +} diff --git a/tests/fixtures/heap_effects/token_out_of_range.rejected.txt b/tests/fixtures/heap_effects/token_out_of_range.rejected.txt new file mode 100644 index 00000000..5a2a8f04 --- /dev/null +++ b/tests/fixtures/heap_effects/token_out_of_range.rejected.txt @@ -0,0 +1 @@ +heap-effect facts: method #0: derefs holds a token outside the vocabulary diff --git a/tests/fixtures/heap_effects/token_out_of_range.sidecar.json b/tests/fixtures/heap_effects/token_out_of_range.sidecar.json new file mode 100644 index 00000000..98a06b4b --- /dev/null +++ b/tests/fixtures/heap_effects/token_out_of_range.sidecar.json @@ -0,0 +1,22 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "A()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [], + "derefs": [ + "local:0" + ], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/unknown_keeps_inert_plain.sidecar.json b/tests/fixtures/heap_effects/unknown_keeps_inert_plain.sidecar.json new file mode 100644 index 00000000..52722be1 --- /dev/null +++ b/tests/fixtures/heap_effects/unknown_keeps_inert_plain.sidecar.json @@ -0,0 +1,56 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "Opaque(int, Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [ + { + "index": 0, + "name": "n", + "inert": true + }, + { + "index": 1, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [ + "expression Await" + ] + }, + { + "key": "OpaqueReturn(Order)", + "file": "synthetic.cs", + "line": 1, + "receiver": true, + "return_inert": false, + "params": [ + { + "index": 0, + "name": "o", + "inert": false + } + ], + "locals": [], + "derefs": [], + "writes": [], + "stores": [], + "returns": [], + "calls": [], + "unknown": [ + "dynamic" + ] + } + ] +} diff --git a/tests/fixtures/heap_effects/unknown_keeps_inert_plain.summaries.json b/tests/fixtures/heap_effects/unknown_keeps_inert_plain.summaries.json new file mode 100644 index 00000000..efa3aa05 --- /dev/null +++ b/tests/fixtures/heap_effects/unknown_keeps_inert_plain.summaries.json @@ -0,0 +1,56 @@ +{ + "heap_effects_version": 1, + "summaries": [ + { + "file": "synthetic.cs", + "line": 1, + "method": "Opaque(int, Order)", + "params": [ + { + "effect": "plain", + "index": 0, + "name": "n" + }, + { + "effect": "unknown", + "index": 1, + "name": "o" + } + ], + "receiver": null, + "returns": [], + "unresolved": [ + "expression Await" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + }, + { + "file": "synthetic.cs", + "line": 1, + "method": "OpaqueReturn(Order)", + "params": [ + { + "effect": "unknown", + "index": 0, + "name": "o" + } + ], + "receiver": "unknown", + "returns": [ + "unknown" + ], + "unresolved": [ + "dynamic" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + } + ] +} diff --git a/tests/fixtures/heap_effects/unknown_root_write.sidecar.json b/tests/fixtures/heap_effects/unknown_root_write.sidecar.json new file mode 100644 index 00000000..83c3455b --- /dev/null +++ b/tests/fixtures/heap_effects/unknown_root_write.sidecar.json @@ -0,0 +1,44 @@ +{ + "heap_effects_version": 1, + "methods": [ + { + "key": "WritesThroughExtern()", + "file": "synthetic.cs", + "line": 1, + "receiver": false, + "return_inert": true, + "params": [], + "locals": [ + { + "id": 0, + "name": "x", + "sources": [ + "call:0" + ] + } + ], + "derefs": [], + "writes": [ + { + "kind": "instance", + "target": [ + "local:0" + ] + } + ], + "stores": [], + "returns": [], + "calls": [ + { + "id": 0, + "callee": "Ext.Get()", + "dispatch": "extern", + "receiver": null, + "args": [], + "line": 1 + } + ], + "unknown": [] + } + ] +} diff --git a/tests/fixtures/heap_effects/unknown_root_write.summaries.json b/tests/fixtures/heap_effects/unknown_root_write.summaries.json new file mode 100644 index 00000000..356dcb2a --- /dev/null +++ b/tests/fixtures/heap_effects/unknown_root_write.summaries.json @@ -0,0 +1,21 @@ +{ + "heap_effects_version": 1, + "summaries": [ + { + "file": "synthetic.cs", + "line": 1, + "method": "WritesThroughExtern()", + "params": [], + "receiver": null, + "returns": [], + "unresolved": [ + "Ext.Get() (extern)" + ], + "writes": { + "indirect": "unknown", + "instance": "unknown", + "static": "unknown" + } + } + ] +} diff --git a/tests/test_heap_effects_fixtures.py b/tests/test_heap_effects_fixtures.py new file mode 100644 index 00000000..0305d0cd --- /dev/null +++ b/tests/test_heap_effects_fixtures.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""H0 heap-effect summaries — the parity fixtures and the kill-fixture semantics. + +`tests/fixtures/heap_effects/` holds heap-effect SOURCE FACTS (`.sidecar.json`) +and what the reference makes of them: + +* `.summaries.json` — the EXACT bytes `python -m ownlang.heap_effects` prints + (`json.dumps(indent=2, sort_keys=True)` + newline); +* `.rejected.txt` — the exact message of a sidecar the reader refuses. + +`samples.sidecar.json` is what the real extractor writes for +`frontend/roslyn/heap-effects-samples/HeapEffects.cs` (scripts/heap_effects_gate.py +re-extracts it and requires it back); every other case is synthetic, aimed at a +solver corner C# does not reach directly. + +Python is authoritative: `--write` regenerates the goldens. The Rust port holds up +its half in `rust/crates/own-bridge/tests/heap_effects.rs` — every golden, byte for +byte, every rejection text, with zero Python. + +Beyond the bytes this pins the MEANING of the kill fixtures (a golden alone would +happily freeze a wrong answer), the order-independence of the solve, and that H0 is +inert: no module of the checker imports the summary domain. + +Run: python tests/test_heap_effects_fixtures.py (verify) + python tests/test_heap_effects_fixtures.py --write (regenerate) +""" + +from __future__ import annotations + +import ast +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.heap_effects import HEAP_EFFECTS_VERSION, HeapEffectsError, render + +ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +FIXDIR = os.path.join(ROOT, "tests", "fixtures", "heap_effects") +MANIFEST = os.path.join(FIXDIR, "manifest.json") +NS = "Own.HeapEffects.Samples." + + +def _read(name: str) -> str: + with open(os.path.join(FIXDIR, name), encoding="utf-8") as f: + return f.read() + + +def _manifest() -> tuple[list[str], list[str], list[str]]: + data = json.loads(_read("manifest.json")) + problems = [] + if data.get("heap_effects_version") != HEAP_EFFECTS_VERSION: + problems.append("manifest heap_effects_version does not match the reader") + return ([c["name"] for c in data["cases"]], [r["name"] for r in data["rejections"]], + problems) + + +def _reversed(text: str) -> str | None: + """The same sidecar with its methods in reverse order (None if it is not one).""" + try: + doc = json.loads(text) + except ValueError: + return None + if not isinstance(doc, dict) or not isinstance(doc.get("methods"), list): + return None + doc = dict(doc, methods=list(reversed(doc["methods"]))) + return json.dumps(doc) + + +def _ledger(cases: list[str], rejections: list[str]) -> list[str]: + fails: list[str] = [] + on_disk = sorted(n[: -len(".sidecar.json")] for n in os.listdir(FIXDIR) + if n.endswith(".sidecar.json")) + planned = sorted(cases + rejections) + if len(set(planned)) != len(planned): + fails.append("manifest names a case twice") + if on_disk != planned: + fails.append(f"sidecars on disk {on_disk} != manifest {planned}") + goldens = sorted(n[: -len(".summaries.json")] for n in os.listdir(FIXDIR) + if n.endswith(".summaries.json")) + if goldens != sorted(cases): + fails.append(f"summaries goldens {goldens} != cases {sorted(cases)}") + texts = sorted(n[: -len(".rejected.txt")] for n in os.listdir(FIXDIR) + if n.endswith(".rejected.txt")) + if texts != sorted(rejections): + fails.append(f"rejection texts {texts} != rejections {sorted(rejections)}") + return fails + + +def _replay(cases: list[str], rejections: list[str], write: bool) -> list[str]: + fails: list[str] = [] + for case in cases: + text = _read(f"{case}.sidecar.json") + out = render(text) + golden = os.path.join(FIXDIR, f"{case}.summaries.json") + if write: + with open(golden, "w", encoding="utf-8", newline="\n") as f: + f.write(out) + elif _read(f"{case}.summaries.json") != out: + fails.append(f"{case}: the dump is not the golden") + flipped = _reversed(text) + if flipped is not None and render(flipped) != out: + fails.append(f"{case}: the dump depends on the order of methods[]") + for case in rejections: + text = _read(f"{case}.sidecar.json") + try: + render(text) + except HeapEffectsError as e: + message = str(e) + else: + fails.append(f"{case}: accepted, but it is a rejection case") + continue + path = os.path.join(FIXDIR, f"{case}.rejected.txt") + if write: + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(message + "\n") + elif _read(f"{case}.rejected.txt") != message + "\n": + fails.append(f"{case}: rejected with {message!r}, not the pinned text") + return fails + + +def _summary(doc: dict[str, object], method: str) -> dict[str, object]: + for s in doc["summaries"]: # type: ignore[attr-defined] + if s["method"] == NS + method: + return s # type: ignore[no-any-return] + raise KeyError(method) + + +def _shape(s: dict[str, object]) -> tuple[object, ...]: + return ([p["effect"] for p in s["params"]], s["receiver"], # type: ignore[attr-defined] + s["writes"], s["returns"]) + + +NONE_W = {"instance": "none", "static": "none", "indirect": "none"} +STATIC_W = dict(NONE_W, static="may") +ALL_UNKNOWN = {"instance": "unknown", "static": "unknown", "indirect": "unknown"} +ORDER = "Own.HeapEffects.Samples.Order" + +# The meaning the slice exists for: (params, receiver, writes, returns). +EXPECT: dict[str, tuple[object, ...]] = { + # the five kill fixtures + "Kill.Twice(int)": (["plain"], None, NONE_W, []), + f"Kill.Mutates({ORDER})": (["borrow_mut"], None, NONE_W, []), + f"Kill.Escapes({ORDER})": (["may_escape"], None, STATIC_W, []), + "Kill.TouchesGlobalState()": ([], None, STATIC_W, []), + "Kill.Logs()": ([], None, ALL_UNKNOWN, []), + # transitive: only B's summary says what A does + f"Transitive.A({ORDER})": (["borrow_mut"], None, NONE_W, []), + f"Transitive.EscapeOuter({ORDER})": (["may_escape"], None, STATIC_W, []), + "Transitive.CallsLogs()": ([], None, ALL_UNKNOWN, []), + f"Transitive.CallsTouch({ORDER})": (["borrow_mut"], None, NONE_W, []), + f"Transitive.CallsPeek({ORDER})": (["borrow"], None, NONE_W, []), + f"Transitive.AliasMutates({ORDER})": (["borrow_mut"], None, NONE_W, []), + f"Transitive.Reassigned({ORDER})": (["borrow_mut"], None, dict(NONE_W, instance="may"), []), + f"Transitive.SetFirst({ORDER}[], {ORDER})": (["borrow_mut", "may_escape"], None, NONE_W, []), + f"Transitive.ReadsDeep({ORDER})": (["borrow"], None, NONE_W, []), + # SCCs + "Recursive.Even(int)": (["plain"], None, NONE_W, []), + "Recursive.Odd(int)": (["plain"], None, NONE_W, []), + f"Recursive.Ping({ORDER}, int)": (["borrow_mut", "plain"], None, NONE_W, []), + f"Recursive.Pong({ORDER}, int)": (["borrow_mut", "plain"], None, NONE_W, []), + f"Recursive.Tick({ORDER}, int)": (["unknown", "plain"], None, ALL_UNKNOWN, []), + f"Recursive.Tock({ORDER}, int)": (["unknown", "plain"], None, ALL_UNKNOWN, []), + # return aliases + f"Returns.Id({ORDER})": (["plain"], None, NONE_W, ["param:0"]), + f"Returns.ViaCall({ORDER})": (["plain"], None, NONE_W, ["param:0"]), + "Returns.FromHeap()": ([], None, NONE_W, ["heap"]), + f"Returns.MutatesResult({ORDER})": (["borrow_mut"], None, NONE_W, []), + # opaque + "Opaque.Virtual(Own.HeapEffects.Samples.IShape)": (["unknown"], None, ALL_UNKNOWN, []), + f"Opaque.Lambda({ORDER})": (["unknown"], None, ALL_UNKNOWN, ["unknown"]), + "Opaque.CallsLazy()": ([], None, ALL_UNKNOWN, []), + "Order.Touch()": ([], "borrow_mut", NONE_W, []), + "Order.Peek()": ([], "borrow", NONE_W, []), + # a captured primary-constructor parameter is hidden state: Unknown + "Holder.Touch()": ([], "unknown", ALL_UNKNOWN, []), +} + + +def _semantics() -> list[str]: + fails: list[str] = [] + doc = json.loads(render(_read("samples.sidecar.json"))) + for method, want in EXPECT.items(): + try: + got = _shape(_summary(doc, method)) + except KeyError: + fails.append(f"samples: no summary for {method}") + continue + if got != want: + fails.append(f"samples: {method} is {got}, expected {want}") + logs = _summary(doc, "Kill.Logs()") + if not any("System.Console.WriteLine" in r for r in logs["unresolved"]): # type: ignore[attr-defined] + fails.append("samples: Kill.Logs() does not name Console.WriteLine as unresolved") + return fails + + +def _inert() -> list[str]: + """H0 is inert: no checker module imports the summary domain.""" + fails: list[str] = [] + pkg = os.path.join(ROOT, "ownlang") + for name in sorted(os.listdir(pkg)): + if not name.endswith(".py") or name == "heap_effects.py": + continue + with open(os.path.join(pkg, name), encoding="utf-8") as f: + tree = ast.parse(f.read()) + for node in ast.walk(tree): + mods = ([a.name for a in node.names] if isinstance(node, ast.Import) + else [node.module or ""] if isinstance(node, ast.ImportFrom) else []) + names = [a.name for a in node.names] if isinstance(node, ast.ImportFrom) else [] + if any(m.endswith("heap_effects") for m in mods) or "heap_effects" in names: + fails.append(f"ownlang/{name} imports heap_effects: H0 must stay inert") + return fails + + +def run(write: bool = False) -> int: + cases, rejections, fails = _manifest() + fails += _replay(cases, rejections, write) + fails += _ledger(cases, rejections) + fails += _semantics() + fails += _inert() + for f in fails: + print(f"FAIL heap_effects: {f}") + print(f"heap_effects: {len(cases)} cases, {len(rejections)} rejections, " + f"{len(EXPECT)} pinned summaries — {'FAIL' if fails else 'PASS'}") + return 1 if fails else 0 + + +if __name__ == "__main__": + raise SystemExit(run(write="--write" in sys.argv[1:]))