From 49f54933135d5f38019567dfdb6c26194092cbe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:10:45 +0000 Subject: [PATCH 1/5] docs(owen-ext): OX-01 preregistration + Snipper salvage audit (base 88cb8cc3) Registers the Owen extension substrate slice before any code: kill-first findings (owen check is already the generic pipeline; explicit generated inputs reach the extractor; incremental generator matches the golden while an MSBuild pre-compile step is absent from design-time builds; a tool package cannot be PackageReference'd, NU1212), the Owen.Build host + Owen.TypedBuilder extension packages, the descriptor contract and its OWENB codes, platforms, isolated-consumer acceptance A-H, mutations M1-M7 and the verdict. The Snipper audit (read-only, PhysShell/snipper@43b395a) records COPY/ADAPT/REJECT per mechanism. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFvhLx1fM9Gerg4dKsZcL --- .../owen-extension-alpha-preregistration.md | 292 ++++++++++++++++++ docs/notes/owen-extension-snipper-salvage.md | 221 +++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 docs/notes/owen-extension-alpha-preregistration.md create mode 100644 docs/notes/owen-extension-snipper-salvage.md diff --git a/docs/notes/owen-extension-alpha-preregistration.md b/docs/notes/owen-extension-alpha-preregistration.md new file mode 100644 index 00000000..1b00727e --- /dev/null +++ b/docs/notes/owen-extension-alpha-preregistration.md @@ -0,0 +1,292 @@ +# OX-01 — Owen extension substrate, with Typed Builder as extension #1: preregistration + +**STATUS: REGISTERED BEFORE ANY PRODUCT CODE.** This commit holds this document and the +Snipper salvage audit ([`owen-extension-snipper-salvage.md`](owen-extension-snipper-salvage.md)), +and nothing else. A semantic change after this commit is an amendment, committed before the +official run it affects. + +**Base:** `main` = `88cb8cc31c49d730290613208947102d0737cad2` (merge of #391, TB-MVP-01). The +base contains #391: `git merge-base --is-ancestor 04cb779a4ce8047e563d35edc72bdce9a13eaca8 88cb8cc3` +holds. `main` has not moved since, and nothing under Typed Builder, `OwnSharp.Cli` or +packaging changed after it. + +## P0 — gates on the base + +The results are in § *P0 results* at the end. + +## Kill-first findings (throwaway spikes on the base, not committed) + +**K-1 — the host already exists.** `owen check` (`frontend/roslyn/OwnSharp.Cli`, the `Owen.Cli` tool) +is a complete generic pipeline: +- bundled extractor, child process; +- facts; +- the authoritative Rust core `own-cli`, resolved only from an explicit locator or one computed packaged path (D3/D6, `RustCoreLocator.cs`); +- `--format msbuild`; +- a mapped exit-code contract. + +On the Rust engine it needs **no Python**: the vendored core is unpacked only for `--engine python|compare`. + +The core already renders the canonical VS Error List line itself (`own-cli ownir --format msbuild`). +A second runner or a second renderer would duplicate the authoritative path, so none is built. + +**K-2 — the protocol must reach the scan as source.** The extractor's directory and `.csproj` +expansion skips `bin/`, `obj/` and `*.g.cs`. An **explicitly named** `.cs` input passes through +unfiltered (`Program.cs::Expand`). + +A probe confirmed the path end to end: +1. the TB-MVP `OrderBackend` sources built with the generated protocol living only under `obj/.../generated/`; +2. the extractor ran on `OrderBackend.csproj` plus that generated file named explicitly; +3. `own-cli ownir --format msbuild` printed `C8.cs(18): error OWN002: …`. + +No extractor change is needed. + +**K-3 — generator delivery, both variants measured.** +- **A (Roslyn incremental source generator).** + - **Setup:** the existing generator's model and renderer, compiled unchanged into a `netstandard2.0` analyzer against Roslyn 4.8.0, run inside the ordinary compilation. + - **Output:** text byte-identical to the committed golden `samples/OrderBackend/OrderBackend/Domain/Order.Protocol.cs`, 6880/6880 bytes. The only Roslyn-specific `string[1..]` uses were rewritten as `Substring(1)`, with the same output. + - **One difference, compiler side:** generated sources compile with nullable annotations off, so the golden's `string?` warns CS8669. The generator wrapper therefore prepends exactly one line, `#nullable enable`, to the golden text. The shared renderer's output, and so the golden, do not change. +- **B (MSBuild pre-compile Exec).** + - **Full build:** works, adding about 0.5 s of `dotnet exec` per build. + - **Design-time build (fails):** under the design-time build Visual Studio uses for IntelliSense (`-t:CompileDesignTime -p:DesignTimeBuild=true -p:SkipCompilerExecution=true -p:ProvideCommandLineArgs=true`), the generated file was **neither produced nor in the compile item list**. IntelliSense would show the typed API as missing until a full build, and an edit to the declaration would not regenerate it. +- **Decision: A.** A runs in every compilation, the IDE's included, and keeps the golden. B is not shipped. + +**K-4 — packaging.** +- **Owen.Cli cannot be the reference.** It is a `DotnetTool` package, and NuGet refuses it as a `PackageReference` (NU1212). A referenceable host package is therefore needed. +- **The exec bit.** A `.nupkg` does not carry Unix file modes. `RustCoreLocator.ResolvePackaged` already copies the packaged `own-cli` into a content-addressed cache `~/.owen/rust-core//` and marks it executable there. The host reuses that code path as is. + +## Install UX (the claim) + +``` +dotnet new web -n OrderBackend +dotnet add package Owen.TypedBuilder --version 0.1.0 +dotnet build +``` + +That is all. The machine needs no Own.NET checkout, no Python, no Rust toolchain, no global +`owen` tool, no environment variable pointing at a repository, and no PATH lookup of a +development binary. The only executable taken from the machine is the `dotnet` muxer that is +already running the build: `DOTNET_HOST_PATH` as MSBuild sets it, then `DOTNET_ROOT`, then +`dotnet`. + +## Packages (exact) + +| package | kind | why it exists | +|---|---|---| +| `Owen.Build` | generic build host | One shared host is needed. It must be referenceable (K-4), carry exactly one copy of the extractor and of each platform's Rust core however many extensions are active, and depend on no extension. It is the only new non-extension package; there is no `Owen.Toolchain` or `Owen.CoreAssets`, because nothing would separate them from it. | +| `Owen.TypedBuilder` | extension #1 | The generator, the extension descriptor, and a dependency on `Owen.Build` of the same version. | +| `Owen.TestExtension` | **test fixture, never shipped** | A synthetic second extension: a descriptor only, no analysis. It is built by the gate into the isolated feed and nowhere else. | + +Version: `0.1.0` for both shipped packages, matching `Owen.Cli` 0.1.0, which the host payload is. +Nothing is published to nuget.org. Every acceptance run uses a locally packed candidate in an +isolated feed. + +**`Owen.Build` layout:** + +| path | contents | +|---|---| +| `tools/net8.0/any/` | the `OwnSharp.Cli` publish closure (`ownsharp.dll`, `ownsharp-extract.dll`, Roslyn), the same payload as the `Owen.Cli` tool, **without** the vendored Python core | +| `tools/net8.0/any/rust-core//own-cli[.exe]` | the packaged core, at the path `RustCoreLocator.PackagedPath` already computes | +| `build/Owen.Build.props` · `build/Owen.Build.targets` | the generic MSBuild host | +| `buildTransitive/…` | the same two files, so they flow through an extension's dependency | + +**One source of truth for the platform inventory.** The set of supported platform keys and +binary names, and the "pack fails when a staged binary is missing" check, move into one +`frontend/roslyn/OwenRustCore.props`. Both `OwnSharp.Cli.csproj` and the `Owen.Build` project +import it. The `Owen.Cli` package payload must stay **file-for-file identical** to its base +payload, compared as a sorted file list plus per-file sha256. + +## Extension discovery contract (exact) + +An extension package adds, from its `buildTransitive/.props`: + +```xml + +``` + +The descriptor (`spec/OwenExtension.md`, `spec/owen-extension.schema.json`): + +```json +{ + "owen_extension": 1, + "id": "Owen.TypedBuilder", + "version": "0.1.0", + "requires": { + "host": "0.1.0", + "ownir": 2, + "capabilities": ["ownership", "state-protocol", "heap-effects", "proven-call"] + }, + "frontend": { "generators": ["Owen.TypedBuilder.Generator"] } +} +``` + +**Host 0.1.0 supports:** OwnIR 2, and the capabilities `ownership`, `state-protocol`, +`heap-effects` and `proven-call`. The host validates every descriptor before any analysis, and +**every violation is a build error**, never a skip. The new code family is `OWENB`, the host's; +no `OWN` code changes. + +| condition | result | +|---|---| +| no descriptor at all (`Owen.Build` referenced alone) | warning `OWENB001` "no Owen extension is active; nothing was analysed". **Intentionally inactive, and loud about it.** | +| `owen_extension` ≠ 1, a missing or ill-typed field, a duplicate `id` | error `OWENB002` | +| `requires.ownir` ≠ 2, or `requires.host` newer than the host | error `OWENB003` (incompatible extension) | +| a required capability the host does not have | error `OWENB004`, naming it and the extension | +| no Rust core for this platform in the package, or an unsupported platform | error `OWENB005` (the locator's D3.1 message) | +| the extractor refuses a construct (exit 2) | error `OWENB010` at the refused `file(line)` | +| the core refuses the facts (exit 2, e.g. a `proven_call` not proven harmless) | error `OWENB011` at the `file(line)` the refusal names | +| an internal failure of either child | error `OWENB012` | + +**What the host does with valid descriptors.** +- **Manifest.** It writes `obj/owen/extensions.json`, sorted by `id`: host version, OwnIR version, and each active extension's `id`, `version` and capabilities. +- **Logging.** It logs `Owen: active extensions: , …` at high importance. +- **Generator outputs.** It collects every `frontend.generators` name, takes the generated C# under `$(CompilerGeneratedFilesOutputPath)//**`, and passes those files to the extractor explicitly (K-2). The host props set `EmitCompilerGeneratedFiles=true`, so those files exist. +- **Analysis.** It runs the existing check pipeline exactly once, whatever the number of extensions, with `--engine rust --format msbuild --severity $(OwenSeverity)`. + +The host contains **no extension identifier**: the gate greps the host sources and targets for +`TypedBuilder` and expects zero hits. Two extensions coexist by construction, because the +descriptors are a list. + +**Where it lives.** +- **Validation, manifest, generated-file collection and refusal canonicalisation:** a new `owen build-check` subcommand of the same `OwnSharp.Cli` program. It is the generic host entry point the targets call, and it reuses `CheckCommand`'s pipeline rather than copying it. +- **Analysis semantics:** none in the host; they stay in the Rust core. + +**MSBuild properties:** +- `OwenSeverity`: `warning` by default, `error` opt-in. + - **Why warning is the default:** the first install never turns a legacy project red, because the existing `--severity` only changes how a finding is shown, never the verdict. + - **Refusals are the exception:** OWENB010/011 are always errors. They only arise in a protocol the developer declared, and "cannot be checked" must never read as clean. +- `OwenEnabled`: `true` by default, `false` to switch the host off. + +The target runs after the build has copied its output, because the extractor binds against the +built `bin/`. It is skipped in design-time builds and runs on every build, so warnings do not +vanish on an incremental build. + +## The Typed Builder extension (exact) + +- **`Owen.TypedBuilder.Generator`:** a `netstandard2.0` `IIncrementalGenerator` over Roslyn 4.8.0. +- **Shared core.** It and the existing CLI (`frontend/roslyn/Own.TypedBuilder`) share one model/renderer library, `Own.TypedBuilder.Core`. The CLI's output stays byte-identical to the golden, so `typed_builder_gate` keeps passing unchanged. +- **The attribute vocabulary** (`TypedProtocol`, `ProtocolState`, `BuilderRequired`, `Transition`, `ProtocolToken`, `ProtocolRegion`) is emitted by the generator's post-initialisation step as `internal` types in the global namespace. A consumer therefore writes `[TypedProtocol]` with no `using` and no dependency, and the generated protocol text stays the golden. +- **A refused declaration** is a generator error `OWENTB001` carrying the core's refusal line. It is never empty output. + +## Supported platforms + +`linux-x64` and `win-x64`: exactly the platforms `Owen.Cli` packs today. macOS is **not** +claimed. On any other platform the host fails with `OWENB005`; it never skips. + +## Foundations: unchanged + +No change to: +- OwnIR (version 2) or the ownership, state-protocol or H0/H1 semantics; +- any `OWN` code or message; +- the Rust core's analysis; +- T0 or the P-022 harness. + +`git diff --stat 88cb8cc3..HEAD -- ownlang rust spec/OwnIR.md spec/Bridge.md spec/ownir.schema.json frontend/roslyn/OwnSharp.Extractor docs/evidence/calibration scripts/perf_baseline.py` +must be empty. If the substrate turns out to need one of these, the slice stops with a named +blocker. + +## Snipper (P1) + +The salvage audit is committed alongside this document. **For this slice:** +- **REJECT** Snipper's binary discovery (setting → bundled → PATH, with silent fall-through); +- **ADAPT** pack-time bundling with a hard failure on a missing binary (already the `Owen.Cli` rule), and the one-shot child-process discipline (drain both streams, map the exit code, kill on cancel). + +Everything else is for a future `Owen.VisualStudio` or LSP and is **not** built here. + +## IDE: investigated only, not shipped + +- **E1 (generic VSIX host: `IErrorTag` + `ITableDataSource` + a long-lived Owen process).** This environment has no Windows and no Visual Studio, so the spike can only be specified. The verdict will be recorded as **not executed**, with the reasons and the exact acceptance it would need. No VSIX is built. +- **E2 (Roslyn `DiagnosticAnalyzer` → native Rust core).** A minimal experimental project under `experiments/owen-e2-native-analyzer/`, outside every shipping path and every CI build. It is exercised on Linux with `dotnet build` and repeated builds through the compiler server. Windows, the Visual Studio x64 in-proc compiler and unload/reload are recorded as not executed here. Verdict: `VIABLE` or `REJECT`. +- **Architecture invariant.** An extension is not an IDE plugin. A future IDE host is exactly one `Owen.VisualStudio`, and a future server is exactly one Owen diagnostic/language server, both serving all active extensions. No `Owen.TypedBuilder.VisualStudio`. + +## Acceptance + +**Gate:** `scripts/owen_extension_gate.py`. It runs on Linux and Windows CI, packs the candidate +packages, and tests them in an isolated consumer. + +**The isolated consumer.** +- **Location:** a fresh directory **outside the checkout**. +- **Environment:** + - `PATH` holds only the directory of the `dotnet` running the gate; + - `HOME`, `DOTNET_CLI_HOME` and `NUGET_PACKAGES` are fresh temporary directories; + - the `nuget.config` has ``, the isolated feed and nuget.org, with package source mapping `Owen.*` → the isolated feed only. +- **Preconditions:** + - `python`, `python3`, `cargo`, `rustc` and `owen` resolve to nothing on that `PATH`; + - the consumer contains no path into the checkout. + +The gate fails if any precondition fails. + +**Steps, every one PASS/FAIL:** + +| id | step | PASS iff | +|---|---|---| +| U1 | `dotnet new web -n OrderBackend`, `dotnet add package Owen.TypedBuilder --version 0.1.0`, then copy in the TB-MVP domain/data/endpoints/Shipping sources. Not `Order.Protocol.cs` (now generated) and not `TypedBuilder.cs` (now provided). | the project file holds exactly one Owen `PackageReference` | +| A | `dotnet build` | succeeds. The generated `Order.Protocol.g.cs` text = `#nullable enable\n` + the committed golden. `obj/owen/extensions.json` lists `Owen.TypedBuilder 0.1.0` and its capabilities | +| B | stage `C1_draft_approve` | the build fails with CS1061 naming `Approve`, and no manual generator step ran | +| C | stage `C8_stale_draft` with `OwenSeverity=error` | the build fails with `OWN002` | +| D | the C build's output | holds a canonical `file(line): error OWN002: …` line, whose file resolves from the project directory to the staged file and whose line is 18. With the default severity, the same build succeeds and shows the line as `warning OWN002` | +| E | U1–D under the stripped environment | all pass; the build logs contain no path into the Own.NET checkout | +| F | the whole TB-MVP corpus (8 positive, 20 negative, 2 limits) through the package, `OwenSeverity=error` | compiler cases: their CS code; extractor cases: `OWENB010` with the registered text; core verdict cases: their `OWN` code(s); core refusals: `OWENB011` with the registered text; positives: no `OWN`/`OWENB` diagnostic. K1 gives `OWN001`, K2 is clean, P5 and P8 have the `proven_call` (from `--emit-facts`) | +| G | the TB-MVP `Acceptance` runner against the consumer project | 44/44, with the transcript **byte-identical** to the committed `samples/OrderBackend/evidence/acceptance.txt` | +| H | the consumer with both `Owen.TypedBuilder` and `Owen.TestExtension` | the manifest lists both, analysis runs once, and the result is unchanged | + +**Mutation and negative controls, each run in the isolated consumer:** + +| id | mutation | PASS iff | +|---|---|---| +| M1 | delete `owen-extension.json` from the installed `Owen.TypedBuilder` | `OWENB001` is shown, and C8 produces **no** `OWN002`: the acceptance's C check would fail, so the gate detects it | +| M2 | add an unknown capability to the descriptor | error `OWENB004` naming it | +| M3 | delete the bundled `own-cli` for this platform | error `OWENB005`, never a clean build | +| M4 | `requires.ownir: 3`, and separately `requires.host: 9.0.0` | error `OWENB003` each | +| M5 | remove the host's build target (empty `Owen.Build.targets`) | C8 produces no `OWN002` and the gate's C check reports the absence | +| M6 | run from a consumer with no checkout | pass (it is E) | +| M7 | add `Owen.TestExtension` | no file of the host changes (H), and the host source has 0 occurrences of `TypedBuilder` | + +**The other gates, on the final head:** +- `tests/run_tests.py`, `ruff`, `mypy`; +- `cargo fmt --check`, `cargo clippy --all-targets`, `cargo test`; +- `protocol_gate.py --rust`, `heap_effects_gate.py`, `typed_builder_gate.py --rust`; +- the `Owen.Cli` pack/install gate (the payload byte-identity above, then install → check → `OWN001`); +- the P-022 merge gate; +- server CI on Linux and Windows. + +## Verdict + +**`GO_OWEN_EXTENSION_ALPHA`** iff A–H, M1–M7 and every listed gate pass: + +`PackageReference Owen.TypedBuilder` → generated API → CS diagnostics → OWN diagnostics on build +→ no external toolchain or setup → generic extension discovery proven. + +Otherwise the verdict is **`NO_GO_OWEN_EXTENSION_ALPHA`**, with the exact blocker. The failing +case is preserved, and no foundation change is made to get to green. If E fails because the +package cannot run without a global Owen install, the verdict is NO_GO. + +**Out of scope even after GO:** +- a production VSIX, LSP or VS Code extension; +- the Memory or TypeDisciplines extensions; +- BCL summaries (#394), typed write targets (#395), affine tokens (#392), diagnostic wording (#393), concurrency (#396); +- a second aggregate; +- a repo-wide rename. + +## P0 results + +Run on `88cb8cc31c49d730290613208947102d0737cad2`, a clean tree except for the two documents of this commit: + +| gate | result | +|---|---| +| `python tests/run_tests.py` | rc 0, zero `FAIL` lines (the one textual hit is an `ok[...]` line quoting the word) | +| `ruff check .` / `mypy` | rc 0 / rc 0 | +| `cargo fmt --check` / `cargo clippy --all-targets` / `cargo test --no-fail-fast` | rc 0 / rc 0 / rc 0, 290 passed, 0 failed | +| `python scripts/protocol_gate.py --rust …/own-cli` | 0 failures | +| `python scripts/heap_effects_gate.py` | PASS | +| `python scripts/typed_builder_gate.py --rust …/own-cli` | 0 failures, transcript sha256 `ba447304cdf957ee` | +| `Owen.Cli` pack/install (gate A, replayed locally) | pass | +| P-022 merge gate | `success` on #391's final head `04cb779`, which became this base | + +`protocol_gate.py` covered 29 documents byte-identical on both CLIs. `typed_builder_gate.py` covered 8 positive, 20 negative and 2 limit cases, with 44 acceptance checks run twice. + +**Gate A, replayed locally.** +- **Pack:** with this platform's release `own-cli` staged under `linux-x64/`. +- **Install:** with `--tool-path` from an isolated feed, with a fresh `HOME` and `NUGET_PACKAGES`. +- **Check:** `owen check` on a seeded leak gives `OWN001` with rc 1; on clean code it gives rc 0. +- **One environmental detail:** this container's dotnet lives in `/root/.dotnet`, so the tool's native apphost needs `DOTNET_ROOT`. The first attempt, without it, failed in the apphost before Owen ran. + +**K-4, observed rather than assumed.** A `PackageReference` to that same `Owen.Cli` 0.1.0 fails restore with `NU1212: Invalid project-package combination … DotnetToolReference project style can only contain references of the DotnetTool type`. diff --git a/docs/notes/owen-extension-snipper-salvage.md b/docs/notes/owen-extension-snipper-salvage.md new file mode 100644 index 00000000..8f1942bf --- /dev/null +++ b/docs/notes/owen-extension-snipper-salvage.md @@ -0,0 +1,221 @@ +# Owen extension substrate: Snipper salvage audit + +- **Audited:** `PhysShell/snipper` at commit `43b395abce6c0c2524d68919cf3c04f9e8f75b7e` (local checkout `/home/user/physshell/snipper`). +- **Date:** 2026-10-03. +- **Scope:** read-only audit. Covers the VS extension (`extensions/snipper-vs/`), the VS Code extension (`extensions/snipper-vscode/`), the Rust LSP adapter (`crates/snipper-lsp/`), the Roslyn sidecar (`sidecar/Snipper.Roslyn/`), the CI workflow (`.github/workflows/ci.yml`) and ADR-0007/0008. The question is which mechanisms Owen should **COPY**, **ADAPT** or **REJECT** for its extension substrate: `Owen.Build` (buildTransitive host), extension packages such as `Owen.TypedBuilder`, and a possible future `Owen.VisualStudio` or Owen LSP/diagnostic server. +- **Path notes:** the requested files exist, but under `extensions/snipper-vs/Snipper.VisualStudio/`, not directly under `extensions/snipper-vs/`. `SnipperLanguageServerProvider.cs` contains a class named `SnipperLanguageClient`, which implements the classic VSSDK `ILanguageClient`. It is not the new `LanguageServerProvider` API. There are two test projects. `Snipper.VisualStudio.IntegrationTests` runs against a **mocked** VS service container. `Snipper.VisualStudio.Tests` holds net8.0 unit tests plus an empty placeholder for the real IDE tests. Real-IDE coverage comes only from the manual script `extensions/snipper-vs/scripts/vs-smoke.ps1`. +- **License:** MIT (`LICENSE`, "Copyright (c) 2026 Snipper contributors"). Code can be copied if the copyright and permission notice go with it. A copied file needs an attribution header, or a `THIRD-PARTY-NOTICES` entry if Owen's license differs. Re-implementing an idea creates no obligation. Because most verdicts below are ADAPT (re-implement), the license matters only for the few literal copies (the test fake-server framing helpers, the smoke-script hive guard). + +All paths below are relative to the Snipper repository root unless they are prefixed with `Own.NET/`. Line numbers refer to the audited commit. + +## Summary + +| # | Mechanism | Snipper location | Verdict | Where it would land in Owen | +|---|---|---|---|---| +| 1a | Binary location: explicit setting -> bundled -> PATH | `extensions/snipper-vs/Snipper.VisualStudio/SnipperBinaryLocator.cs:18-42`, `extensions/snipper-vscode/src/serverPath.ts:13-40` | **REJECT** (PATH fallback, silent fall-through on a bad explicit path) | none. Owen keeps `RustCoreLocator` | +| 1b | Bundling the native binary into the host package at build time (MSBuild target copies `cargo` output into the package `bin/`) | `extensions/snipper-vs/Snipper.VisualStudio/Snipper.VisualStudio.csproj:78-111` | **ADAPT** | Owen.Build packing (`rust-core/{rid}/own-cli`) | +| 1c | Per-platform targeted packages, one binary per RID | `.github/workflows/ci.yml:136-206`, `serverPath.ts:31-40` | **ADAPT** | Owen.Build packaging / future Owen.VisualStudio | +| 2a | Long-lived LSP child (VS `ILanguageClient.ActivateAsync`) | `SnipperLanguageServerProvider.cs:34-54` | **REJECT** as a lifecycle model (no stderr, no exit/crash handling, no orphan control) | none now. Future Owen.VisualStudio must design its own | +| 2b | Short-lived child per command (spawn, handshake, best-effort shutdown, `Kill` in `finally`) | `SnipperCommands.cs:60-92`, `SnipperLspRpc.cs:20-51` | **ADAPT** (the `finally`-kill shape only) | Owen.Build one-shot invocations | +| 2c | Lazy sidecar child inside the Rust server with a 200 ms timeout and a "set to None" reset | `crates/snipper-lsp/src/lib.rs:100-174` | **REJECT** | none | +| 3a | LSP base-protocol framing via StreamJsonRpc `HeaderDelimitedMessageHandler` | `SnipperLspRpc.cs:26-29` | **COPY** (pattern), only if Owen ever speaks LSP from .NET | future Owen.VisualStudio | +| 3b | Newline-delimited JSON-RPC for the sidecar | `sidecar/Snipper.Roslyn/Program.cs:3-15,26-59`, `lib.rs:138-160`, ADR-0007 | **REJECT** for Owen's build path (Owen is one-shot, file/stdout based) | none | +| 4 | Cancellation | `SnipperLspRpc.cs:31-40` (token passed through), tower-lsp built-in `$/cancelRequest`; nothing in Snipper's own code | **REJECT** (nothing to salvage) | future LSP must design it | +| 5 | Stale document / version rejection | `lib.rs:34-38,219-249`: absent | **N/A** (does not exist) | future LSP must design it | +| 6a | Mock-VS integration tests (`Microsoft.VisualStudio.Sdk.TestFramework.Xunit`, net472, windows-latest) | `Snipper.VisualStudio.IntegrationTests/*.csproj:21`, `GlobalFixtures.cs:7-10`, `ci.yml:223-241` | **ADAPT** | future Owen.VisualStudio | +| 6b | In-memory fake JSON-RPC server over two one-directional `Pipe`s | `SnipperCommandRpcTests.cs:15-22,90-154` | **COPY** | future Owen.VisualStudio / LSP client tests | +| 6c | Real-IDE smoke via a dedicated `/rootsuffix` hive (manual PowerShell, not in CI) | `extensions/snipper-vs/scripts/vs-smoke.ps1` | **ADAPT** (manual gate only) | future Owen.VisualStudio | +| 6d | VS Code headless extension tests (`@vscode/test-electron` + `xvfb-run`) | `extensions/snipper-vscode/test/`, `ci.yml:109-134` | **ADAPT** (if a VS Code client is ever built) | none now | +| 7 | Thin VS Code client (`vscode-languageclient`, stdio, command -> `workspace/executeCommand`) | `extensions/snipper-vscode/src/extension.ts:14-43`, `commands.ts:13-58` | **ADAPT** (shape), REJECT its locator | future (not on the roadmap) | +| 8 | LSP-type isolation (INV-5) | `crates/snipper-lsp/tests/inv5_lsp_types_isolation.rs:1-22`, `lib.rs:1-5` | **ADAPT** (the principle; the check is too weak) | Owen Rust core and any future LSP crate | +| 9 | Roslyn sidecar (receiver-type lookup over stdin/stdout) | `sidecar/Snipper.Roslyn/Program.cs`, `lib.rs:83-174`, ADR-0007 | **REJECT** (it overlaps with the extractor and is weaker) | none | + +## 1. Binary location + +**Evidence.** +- VS: `SnipperBinaryLocator.Resolve` (`extensions/snipper-vs/Snipper.VisualStudio/SnipperBinaryLocator.cs:11-30`) checks three places in order: + 1. The Tools > Options `ServerPath`, used only `if File.Exists` (`:20-21`). + 2. `{assemblyDir}/bin/snipper-lsp.exe` (`:23-26`). + 3. A manual PATH scan (`:28-42`). + + The binary name is hard-coded to `snipper-lsp.exe` (`:9`). If the configured path does not exist, the locator silently falls through to the bundled copy or to PATH (`:20`). The test `SnipperBinaryLocatorTests.Resolve_NonExistentConfiguredPath_DoesNotReturn` (`Snipper.VisualStudio.IntegrationTests/SnipperBinaryLocatorTests.cs:25-31`) asserts nothing: its comment accepts either "null or a valid PATH hit". The options page text documents the PATH discovery (`SnipperOptionsPage.cs:10-11`). +- VS Code: `resolveServerPath` (`extensions/snipper-vscode/src/serverPath.ts:13-29`) returns the user setting **without checking that it exists** (`:17-19`), then `bin//snipper-lsp[.exe]`, then the bare name `snipper-lsp` for the OS to resolve on PATH (`:28`). `getPlatformDir` (`:31-40`) maps every non-Windows, non-macOS platform to `linux-x64`, so a linux-arm64 host would look for an x64 binary. +- Bundling: the VSIX project runs `cargo build -p snipper-lsp` and copies the output into `bin/` with `VSIXSubPath=bin` (`Snipper.VisualStudio.csproj:78-97`). With `SnipperLspCargoBuild=false` the target only includes the binary `Condition="Exists(...)"` (`:99-111`), so a missing binary produces a VSIX without a server and no error. Only the manual smoke script checks VSIX contents (`vs-smoke.ps1:157-186`). +- Per-RID packaging: VS Code builds one targeted VSIX per `vsce --target` (`ci.yml:136-206`). +- Sidecar: `SNIPPER_ROSLYN` env var only. If the variable is unset or the path is missing, the sidecar is silently disabled (`crates/snipper-lsp/src/lib.rs:87-95`, ADR-0007 `:38-40`). The VS Code `roslynPath` setting is sent as `initializationOptions` (`extension.ts:53-63`), but the server's `initialize` ignores its params (`lib.rs:178`), so that setting has no effect. The VS options page `RoslynPath` (`SnipperOptionsPage.cs:16`) is never read. + +**Verdict.** +- **1a REJECT.** Every resolution order here ends in PATH discovery, and a bad explicit setting falls through instead of failing. That is the stale-binary failure that Owen's D3 rule prevents (`Own.NET/frontend/roslyn/OwnSharp.Cli/RustCoreLocator.cs`: "No discovery of any kind"; an empty or malformed `OWEN_RUST_CORE` is an exit-2 error, not a fall-through). The VS Code variant does not even check existence. The sidecar locator silently disables a feature on a typo. None of this belongs in Owen. +- **1b ADAPT.** Copying a separately built native binary into a fixed sub-path of the .NET package at pack time does fit Owen's "one computed packaged path". Changes for Owen.Build: + - Do not run `cargo build` from the consumer-facing project. Stage prebuilt per-RID `own-cli` binaries as package content under `rust-core/{rid}/`. + - Make a missing binary a **pack-time error**, not an `Exists()` skip. + - Record the SHA-256 at pack time, so the packaged path can be checked against `RustCore.Sha256` as `RustCoreLocator` already records it. +- **1c ADAPT.** Per-RID targeting is sound. Owen should derive the RID from `RuntimeInformation` the way `RustCoreLocator.PlatformKey()` already does, not from a hand-written if/else that defaults to linux-x64. + +## 2. Long-lived child-process lifecycle + +**Evidence.** +- VS LSP child: `SnipperLanguageClient.ActivateAsync` (`SnipperLanguageServerProvider.cs:34-54`) calls `Process.Start` with only stdin/stdout redirected. Stderr is not redirected and not captured. The `Process` handle is not stored, there is no `Exited` handler, and there is no Job Object or kill-on-close. The `StopAsync` event is declared but never raised (`:30-32`). Initialize failures are swallowed: `ShowNotificationOnInitializeFailed => false` (`:27`) and `OnServerInitializeFailedAsync` returns null (`:61-63`). Restart and shutdown are left to VS's `ILanguageClient` infrastructure. ADR-0008 lists "server restarts on crash" as an extension responsibility (`docs/adr/0008-editor-extension-packaging.md:63`), but no extension implements it. +- VS command child: `SnipperCommandBase.ExecuteCommandAsync` (`SnipperCommands.cs:60-92`) starts a **new** server process for each command invocation (`:68-76`). It runs a full initialize, executeCommand, shutdown, exit handshake (`SnipperLspRpc.cs:20-51`) and then calls `process.Kill()` in `finally`, catching all exceptions (`SnipperCommands.cs:88-91`). Teardown errors are swallowed (`SnipperLspRpc.cs:42-48`). The exit code is never read. The command sends no `textDocument` or position arguments, unlike the VS Code client (`commands.ts:36-44`). +- VS Code: lifecycle is delegated entirely to `vscode-languageclient` (`extension.ts:19-22,36,41-43`). That library provides restart-on-crash with backoff and stop on deactivate. +- Rust server -> sidecar: `try_spawn_sidecar` (`lib.rs:100-116`) sends stderr to null (`:105`) and does not set `kill_on_drop`. tokio's default is false, so dropping `Child` does not kill the process. On a write error or a 200 ms timeout the state is reset to `None` (`:151,171`). The next completion **respawns** the sidecar (`:130-132`), which contradicts ADR-0007's "CST-only for the remainder of the session" (`docs/adr/0007-roslyn-sidecar-protocol.md:34-36`). Orphans are avoided only because the sidecar exits on stdin EOF (`Program.cs:14-15,27`). A sidecar that is busy and timed out keeps running until it finishes and reads EOF. The `Mutex` is held across the 200 ms wait (`lib.rs:128-160`), which serializes completions. `main` has no signal or parent-death handling (`crates/snipper-lsp/src/main.rs:6-12`). + +**Verdict.** +- **2a REJECT** as a model. It is the minimum `ILanguageClient` example: no stderr capture, no exit observation, swallowed init failures, no orphan control. A future Owen.VisualStudio would need at least these: + - Capture stderr into an output pane. + - Use a Windows Job Object with `KILL_ON_JOB_CLOSE` so devenv crashes do not leave orphans. + - Bound the restart policy. + - Surface a visible error when the server cannot be resolved, instead of returning null. +- **2b ADAPT.** Only the shape transfers: spawn, talk, and always terminate in `finally`. For Owen.Build's one-shot `own-cli` invocations: + - Redirect **and drain** stderr concurrently with stdout to avoid pipe deadlock. + - Await `WaitForExitAsync` with a timeout before any kill. + - Read and propagate `ExitCode` through Owen's ratified exit-code mapping (`CrashReport`/D5 carriers already exist in `Own.NET/frontend/roslyn/OwnSharp.Cli`). + - Use `Kill(entireProcessTree: true)` only on timeout or cancellation. + - Never swallow teardown failures silently. + + The per-command full LSP handshake is overhead that Owen has no reason to copy. +- **2c REJECT.** The lazy spawn, null stderr, no kill-on-drop and silent respawn add up to unobservable failure modes. The docs and the code disagree about the post-timeout behavior. + +## 3. RPC framing + +**Evidence.** +- VS side: StreamJsonRpc `HeaderDelimitedMessageHandler` with `JsonMessageFormatter` (`SnipperLspRpc.cs:26-29`). This is standard LSP base-protocol `Content-Length` framing. +- Rust server: tower-lsp 0.20 (`Cargo.toml:33`, `crates/snipper-lsp/Cargo.toml:27`) over stdio (`main.rs:6-12`). +- Test clients: hand-rolled `Content-Length` readers and writers in Rust (`crates/snipper-lsp/tests/smoke.rs:9-41`) and C# (`SnipperCommandRpcTests.cs:156-230`). The Rust `write_msg` uses `body.len()` (bytes), which is correct. The C# test helper reads the header one byte at a time. +- Sidecar: newline-delimited JSON-RPC (`Program.cs:3,26-59`; `lib.rs:138-160`). Unknown methods get `result: null` (`Program.cs:53-58`). + +**Verdict.** +- **3a COPY (pattern)**, and only for a future .NET-side LSP client. If Owen.VisualStudio ever talks to an Owen server outside `ILanguageClient`, StreamJsonRpc's header-delimited handler is the right off-the-shelf choice. Do not write a custom framer. For a Rust server, tower-lsp (or `lsp-server`) is the obvious equivalent; the choice belongs to the future server design. +- **3b REJECT** for Owen now. The `own-cli` contract is one-shot: facts in, rendered diagnostics out, exit code. A line-delimited RPC channel adds framing, timeouts and correlation for no benefit. It also breaks if any payload ever contains a raw newline that is not JSON-escaped, and it has no way to resynchronize after a partial line. + +## 4. Cancellation + +**Evidence.** Snipper's own code does almost nothing with cancellation. +- `SnipperLspRpc` passes the `CancellationToken` to `InvokeWithParameterObjectAsync` (`SnipperLspRpc.cs:31-40`). Cancelling a StreamJsonRpc call sends `$/cancelRequest` to the server. +- The `finally { Kill }` (`SnipperCommands.cs:88-91`) is the effective cancellation of the child process. +- tower-lsp handles `$/cancelRequest` by dropping the pending handler future. Snipper's handlers do not check for cancellation. `query_receiver_type` is bounded only by its 200 ms timeout (`lib.rs:156-160`), and it does not propagate cancellation to the sidecar. The sidecar protocol has no cancel message (`Program.cs:3-12`). + +**Verdict: REJECT, nothing to salvage.** For Owen.Build, cancellation means MSBuild's `ICancelableTask.Cancel()`, which should kill the `own-cli` process tree. Implement that directly in the build task. A future Owen LSP server must design `$/cancelRequest` handling and propagate cancellation into analysis. Snipper has no example of either. + +## 5. Stale document / version rejection + +**Evidence.** +- `DocumentState` stores only `text` and `language_id`, with no version (`lib.rs:34-38`). +- `did_open` inserts the document (`:219-227`). `did_change` overwrites the text with the last change and ignores `text_document.version` (`:242-249`). This is valid only because sync is `FULL` (`:187-189`). +- There is no `did_close`, so the document map only grows. +- Completion and code-action results are computed on a snapshot without any version check (`:274-282,330-334`). +- Neither the sidecar request (`lib.rs:138-143`) nor the command result carries a version. + +**Verdict: N/A, the mechanism does not exist.** A future Owen diagnostic server must: +- key published diagnostics on the document version; +- drop results computed for a superseded version; +- handle `didClose`. + +Owen.Build has no document versions. Its staleness concern is a different one: running a stale *binary*. `RustCoreLocator` already handles that (D3/D6 plus the recorded SHA-256). + +## 6. VS integration tests + +**Evidence.** +- `Snipper.VisualStudio.IntegrationTests` targets **net472** and uses `Microsoft.VisualStudio.Sdk.TestFramework.Xunit` 17.11.66 (`Snipper.VisualStudio.IntegrationTests.csproj:7,21`). That package provides a **mocked** VS service container through the `MockedVS` collection (`GlobalFixtures.cs:7-10`). No IDE instance is started. +- Production sources are linked as `Compile Include` items instead of a project reference, because VSIX outputs break test discovery (`.csproj:50-60`). +- CI runs these tests on `windows-latest` with plain `dotnet test` (`.github/workflows/ci.yml:223-241`). +- The tests are thin: + - `SnipperLanguageClientTests.ActivateAsync_BinaryNotFound_ReturnsNull` (`SnipperLanguageClientTests.cs:16-23`) assumes no `snipper-lsp.exe` is on the runner's PATH. + - The locator tests mostly assert "does not throw" (`SnipperBinaryLocatorTests.cs:25-43`). +- The most reusable piece is `SnipperCommandRpcTests` (`SnipperCommandRpcTests.cs`). It builds an in-memory fake JSON-RPC server over **two one-directional `System.IO.Pipelines.Pipe`s**, specifically so that swapping the input and output stream arguments fails the test (`:15-22,90-101`). It also asserts message order (`:62-81`). +- Real experimental-instance tests: `Snipper.VisualStudio.Tests/IntegrationTests.cs:1-39` contains only commented-out `Microsoft.VisualStudio.Extensibility.Testing.Xunit` examples and a TODO. The project itself is net8.0 unit tests that link two BCL-only files (`Snipper.VisualStudio.Tests.csproj:4,22-23`) and run on ubuntu (`ci.yml:208-221`). +- `extensions/snipper-vs/scripts/vs-smoke.ps1` is a manual real-IDE smoke that does not run in CI. It: + - selects VS with `vswhere` (`:37-85`); + - refuses the shared `Exp` hive and requires a dedicated `/rootsuffix` (`:141-143,399-401`); + - builds and deploys with `DeployExtension=true` (`:427-439`); + - checks VSIX contents (`:157-210`); + - launches `devenv /rootsuffix ... /log`, with one relaunch when the ActivityLog asks for a restart (`:487-526`); + - proves the package loaded from the loaded module list or the ActivityLog (`:363-397`); + - opens a file through `devenv /command File.OpenFile` and waits for `snipper-lsp.exe` as a child of devenv (`:546-567`); + - closes devenv, force-killing it after 15 s (`:577-588`). + +**Verdict.** +- **6a ADAPT** for a future Owen.VisualStudio. Mock-VS xunit on windows-latest is CI-viable and cheap. Add assertions that actually fail. Do not depend on the runner's PATH contents. +- **6b COPY.** The two-pipe fake server is short, correct and generic. Attribute it under MIT if copied literally. +- **6c ADAPT as a manual or nightly gate.** The dedicated-hive guard, `vswhere` selection, ActivityLog evidence and process-parentage check are solid techniques. Hosted runners need VS installed and many minutes per run. Snipper itself never got this into CI, and its intended `Extensibility.Testing` route is still a TODO. Do not plan Owen gates around experimental-instance tests in CI. +- **6d ADAPT** only if a VS Code client is ever built. `@vscode/test-electron` under `xvfb-run` on ubuntu works in CI (`ci.yml:109-134`). Snipper's tests check only registration and activation (`test/suite/extension.test.ts:7-50`), and one of them relies on a 500 ms sleep (`:22-24`). + +None of 6a-6d applies to Owen.Build. The right test for a buildTransitive host is a `dotnet build` of a fixture project that asserts on MSBuild output and the exit code. + +## 7. Thin VS Code client + +**Evidence.** +- `extension.ts:14-43`: `LanguageClient` over stdio with `documentSelector` for `csharp`, `client.start()` in `activate`, and `client.stop()` in `deactivate`. +- `commands.ts:13-58`: each command sends `workspace/executeCommand` with the document URI and position, and inserts the returned string through `editor.action.insertSnippet`. +- Command IDs are generated from TOML by an xtask into both clients (`src/commands.generated.ts`, `extensions/snipper-vs/Generated/SnipperCommands.cs`; ADR-0009). +- Activation is `onLanguage:csharp` (`package.json:2`). +- The `snipper.serverPath` setting advertises PATH discovery (`package.json:34`). + +**Verdict: ADAPT (shape only), not on Owen's roadmap.** "No engine logic in the client, all behavior in the server" matches Owen's rule that the Rust core is authoritative. If a VS Code client ever exists, it should: +- use `vscode-languageclient` the same way; +- generate its contributed IDs from one source; +- replace `serverPath.ts` with the D3/D6 rule: an explicit setting that must exist and fails visibly, otherwise the one packaged per-RID path, with no PATH fallback. + +## 8. LSP-type isolation + +**Evidence.** +- `crates/snipper-lsp/src/lib.rs:1-5,56-57` states INV-5: LSP types stay in the adapter crate. Conversions happen at the boundary (`core_range_to_lsp` / `core_range_from_lsp` / `lsp_pos_to_byte`, `lib.rs:445-509`). +- The enforcement test `tests/inv5_lsp_types_isolation.rs:5-22` checks that the **text** of `snipper-core/Cargo.toml` and `snipper-context/Cargo.toml` does not contain `"lsp-types"`. It would not catch a `tower-lsp` dependency, which re-exports `lsp_types`, or any other LSP crate. +- `cargo public-api` runs only on `snipper-core`, as `continue-on-error: true` (`ci.yml:71-97`). +- `docs/architecture.md:105` calls the test a "compilation test". It is not one. + +**Verdict: ADAPT.** The principle is right for Owen: the Rust core's facts, diagnostics and renderers must not depend on LSP protocol types, so the core stays usable by `own-cli`, Owen.Build and any future server. Enforce it more strongly than Snipper does. Options: +- `cargo tree -e normal -p ` in CI, failing on `lsp-types`, `tower-lsp`, `lsp-server` or `async-lsp`; +- `cargo-deny` `[bans]` scoped to the core crates; +- a crate-graph rule. + +Keep LSP position conversions (UTF-16 columns) in the adapter only, as Snipper does. + +## 9. Roslyn sidecar + +**Evidence.** `sidecar/Snipper.Roslyn/Program.cs` is a net8.0 self-contained single-file executable (`Snipper.Roslyn.csproj:5-13`) using `Microsoft.CodeAnalysis.CSharp` 4.9.2 (`:18`). It reads newline-delimited JSON-RPC requests until stdin EOF (`Program.cs:26-59`). It supports one method, `receiverType {source, offset}`. For each request it: +- parses the **single file** passed in the request (`:63`); +- builds a fresh `CSharpCompilation` with three BCL references only (`:64-67,99-106`); +- walks up to a `MemberAccessExpressionSyntax` and returns the receiver type, its interfaces and its base chain as display strings (`:69-97`). + +There is no project or workspace, no NuGet references, and no caching between requests. Further problems: +- **Offset unit mismatch.** Rust sends a UTF-8 byte offset (`lib.rs:284,300`; ADR-0007 `:60-61`), but `root.FindToken` expects a UTF-16 position (`Program.cs:71`). The two diverge whenever a non-ASCII character comes before the cursor. +- The ADR's "Roslyn workspace initialisation" warm-up (`docs/adr/0007-roslyn-sidecar-protocol.md:30-32`) describes a workspace that does not exist. +- Exceptions are converted to `types: []` (`Program.cs:46-47`). + +**Overlap with Owen's extractor: yes, and Owen's is the stronger component.** Owen's C# extractor (`Own.NET/frontend/roslyn/OwnSharp.Extractor`) already builds real compilations from project inputs and emits facts to the Rust core. Snipper's sidecar is a per-keystroke single-file semantic query with incomplete references. + +**Verdict: REJECT.** Do not import it, its protocol or its lifecycle. One idea is noted only as a future consideration: degrading gracefully to syntax-only behavior when semantic data is unavailable. Owen's diagnostics contract is authoritative, so any such degradation would have to be explicit and visible, never silent as it is in Snipper (`lib.rs:87-95`, ADR-0007 `:38-40,105-110`). + +## Not adopted as backbone + +Snipper's LSP (`snipper-lsp` plus the VS and VS Code thin clients) is **not** a candidate for Owen's mandatory spine. Reasons: + +1. **Wrong product shape.** Owen's authoritative path is a batch pipeline: the extractor emits facts, `own-cli` renders human/github/msbuild/sarif output, and the run sets an exit code. It must work under `dotnet build`, in CI and on headless agents with no editor. An LSP spine would make a long-lived, editor-session-scoped process the center of a system whose main consumer is a build. +2. **Snipper's LSP is an editor-completion adapter, not a diagnostics server.** + - It does not publish diagnostics (`lib.rs:185-206`: completion, code actions and executeCommand only). + - It has no document versioning (section 5) and no cancellation design (section 4). + - It has no lifecycle robustness (section 2). + - Its configuration contract has drifted: `initializationOptions.roslynPath` is ignored (`lib.rs:178` vs `extension.ts:53-63`, ADR-0008 `:41-42`). + + Adopting it would mean rebuilding most of it. +3. **It conflicts with Owen's locator rule.** Every Snipper client ends in PATH discovery (section 1). A backbone built on it would reintroduce exactly what D3 prohibits. +4. **Sequencing.** Owen.VisualStudio and an Owen LSP/diagnostic server are explicitly future work. If they arrive, they should be thin clients of Owen's own core, sitting beside Owen.Build, not underneath it. The extension contract (packages declaring themselves to `Owen.Build`) must not depend on any long-lived server. + +## Applies to this slice (Owen extension Alpha) vs later + +**Matters now (Owen.Build, buildTransitive host, one-shot child processes):** + +| Item | Verdict | Action for Alpha | +|---|---|---| +| 1a Binary location | REJECT | Keep `RustCoreLocator` semantics (explicit `OWEN_RUST_CORE` or the one computed packaged path, no PATH, a bad explicit value is a visible error). Apply the same rule to the extractor binary that Owen.Build ships. | +| 1b Bundle native binary at pack time | ADAPT | Pack prebuilt `rust-core/{rid}/own-cli[.exe]`. Fail the pack when any RID binary is missing. Record SHA-256. | +| 1c Per-RID packaging | ADAPT | Derive the RID from the runtime, not from an if/else that defaults to linux-x64. Decide between a fat multi-RID package and per-RID packages. | +| 2b One-shot child lifecycle | ADAPT | Redirect and drain stdout and stderr concurrently. Wait with a timeout. Map the exit code through Owen's ratified codes. Kill the process tree on timeout or MSBuild cancel. Surface stderr into the MSBuild log or crash report, never swallow it. | +| 4 Cancellation (build flavour) | REJECT Snipper's; implement fresh | `ICancelableTask.Cancel()` -> kill the `own-cli` tree. | +| 8 Protocol-type isolation | ADAPT | Add a CI dependency-graph check now, cheaply. It keeps the core usable by a later server without a refactor. | + +**Only for a future Owen.VisualStudio / Owen LSP server:** +2a (long-lived server lifecycle, which needs a fresh design), 3a (StreamJsonRpc header framing), 5 (version rejection, which needs a fresh design), 6a/6b/6c (mock-VS tests, two-pipe fake server, dedicated-hive smoke), 6d and 7 (VS Code client and headless tests, not on the roadmap). + +**Not applicable at any stage:** 2c and 3b (sidecar lifecycle and line-delimited RPC), 9 (Roslyn sidecar; Owen's extractor already covers this ground with real compilations). From 6e65bfb8eb28098c95edc32438e5e3e82fbd967c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:41:46 +0000 Subject: [PATCH 2/5] feat(owen-ext): Owen.Build host + Owen.TypedBuilder extension #1 (OX-01) Implements docs/notes/owen-extension-alpha-preregistration.md (with its Amendment 1, committed here before any official run): - Owen.Build: the generic build host package. Packs the OwnSharp.Cli publish closure (the owen program, bundled extractor, rust-core/) minus the Python core, plus build/buildTransitive targets that run 'owen build-check' after every build. - owen build-check: validates extension descriptors (OWENB001-005), writes obj/owen/extensions.json, scans the project plus the declared generators' output, runs the Rust core once with --format msbuild, and turns extractor/core refusals into navigable OWENB010/011 errors. It names no extension. - Owen.TypedBuilder: the Typed Builder as a Roslyn incremental generator over the SAME model/renderer as the CLI (TypedBuilderCore.cs, shared), plus the extension descriptor; output = '#nullable enable' + the golden. - OwenRustCore.props: the one platform inventory, used by Owen.Cli and Owen.Build. - spec/OwenExtension.md + schema; scripts/owen_extension_gate.py (isolated consumer A-H, mutations M1-M7) and the Linux/Windows CI job; a synthetic second extension under tests/owen-extensions. No change under ownlang/, rust/, the extractor, OwnIR spec, T0 or calibration. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFvhLx1fM9Gerg4dKsZcL --- .github/workflows/ci.yml | 32 + .../owen-extension-alpha-preregistration.md | 24 + frontend/roslyn/Owen.Build/Owen.Build.csproj | 75 ++ frontend/roslyn/Owen.Build/README.md | 20 + .../roslyn/Owen.Build/build/Owen.Build.props | 15 + .../Owen.Build/build/Owen.Build.targets | 60 ++ .../Owen.TypedBuilder.csproj | 65 ++ .../roslyn/Owen.TypedBuilder/Polyfills.cs | 7 + frontend/roslyn/Owen.TypedBuilder/README.md | 32 + .../TypedBuilderGenerator.cs | 152 ++++ .../buildTransitive/Owen.TypedBuilder.props | 8 + .../buildTransitive/owen-extension.json | 13 + frontend/roslyn/OwenRustCore.props | 40 + frontend/roslyn/Own.TypedBuilder/Program.cs | 405 +--------- .../Own.TypedBuilder/TypedBuilderCore.cs | 423 +++++++++++ .../roslyn/OwnSharp.Cli/BuildCheckCommand.cs | 397 ++++++++++ frontend/roslyn/OwnSharp.Cli/CheckCommand.cs | 9 +- .../roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj | 12 +- frontend/roslyn/OwnSharp.Cli/Program.cs | 15 + scripts/owen_extension_gate.py | 695 ++++++++++++++++++ spec/OwenExtension.md | 112 +++ spec/owen-extension.schema.json | 38 + .../Owen.TestExtension.csproj | 22 + .../buildTransitive/Owen.TestExtension.props | 5 + .../buildTransitive/owen-extension.json | 10 + 25 files changed, 2274 insertions(+), 412 deletions(-) create mode 100644 frontend/roslyn/Owen.Build/Owen.Build.csproj create mode 100644 frontend/roslyn/Owen.Build/README.md create mode 100644 frontend/roslyn/Owen.Build/build/Owen.Build.props create mode 100644 frontend/roslyn/Owen.Build/build/Owen.Build.targets create mode 100644 frontend/roslyn/Owen.TypedBuilder/Owen.TypedBuilder.csproj create mode 100644 frontend/roslyn/Owen.TypedBuilder/Polyfills.cs create mode 100644 frontend/roslyn/Owen.TypedBuilder/README.md create mode 100644 frontend/roslyn/Owen.TypedBuilder/TypedBuilderGenerator.cs create mode 100644 frontend/roslyn/Owen.TypedBuilder/buildTransitive/Owen.TypedBuilder.props create mode 100644 frontend/roslyn/Owen.TypedBuilder/buildTransitive/owen-extension.json create mode 100644 frontend/roslyn/OwenRustCore.props create mode 100644 frontend/roslyn/Own.TypedBuilder/TypedBuilderCore.cs create mode 100644 frontend/roslyn/OwnSharp.Cli/BuildCheckCommand.cs create mode 100644 scripts/owen_extension_gate.py create mode 100644 spec/OwenExtension.md create mode 100644 spec/owen-extension.schema.json create mode 100644 tests/owen-extensions/Owen.TestExtension/Owen.TestExtension.csproj create mode 100644 tests/owen-extensions/Owen.TestExtension/buildTransitive/Owen.TestExtension.props create mode 100644 tests/owen-extensions/Owen.TestExtension/buildTransitive/owen-extension.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adce4d88..105c738a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3455,6 +3455,38 @@ jobs: path: ${{ runner.temp }}/benchmark-scorecard.json retention-days: 90 + # OX-01 (docs/notes/owen-extension-alpha-preregistration.md): the Owen extension substrate, + # with Typed Builder as extension #1. The gate packs Owen.Build (the generic host, with this + # platform's natively built Rust core) and Owen.TypedBuilder, then proves in a project OUTSIDE + # the checkout, with no Python / cargo / rustc / owen on PATH, that one PackageReference gives + # the generated API, CS errors, and OWN findings from `dotnet build` with canonical MSBuild + # coordinates; it replays the whole TB-MVP corpus and acceptance through the package, adds a + # second (synthetic) extension, and mutates the installed packages (M1-M7). Both platforms: + # MSBuild's Exec, the packed core and the paths all differ between them. + owen-extension: + name: Owen extension substrate (Owen.Build + Owen.TypedBuilder, isolated consumer) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: "8.0.x" + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + - name: The extension gate (pack, isolated consumer A-H, mutations M1-M7) + run: python scripts/owen_extension_gate.py + # Alpha gate A (issue #202): the single delightful command, proven end-to-end # on a clean runner — install -> check -> findings. Packaging only, no # analysis-behaviour change: the underlying project stays OwnSharp.Cli diff --git a/docs/notes/owen-extension-alpha-preregistration.md b/docs/notes/owen-extension-alpha-preregistration.md index 1b00727e..29c8e002 100644 --- a/docs/notes/owen-extension-alpha-preregistration.md +++ b/docs/notes/owen-extension-alpha-preregistration.md @@ -266,6 +266,30 @@ package cannot run without a global Owen install, the verdict is NO_GO. - a second aggregate; - a repo-wide rename. +## Amendment 1 (before the official run) + +These are corrections found while building the gate. They were committed before any official +run. + +1. **M1 splits in two.** + - **M1a:** delete `owen-extension.json` from the installed `Owen.TypedBuilder`, but keep its props, which still declares the descriptor. Expect **error `OWENB002`** ("the extension descriptor cannot be read"), not the registered `OWENB001`. + - **M1b:** remove the extension's whole declaration (props and descriptor). Expect warning `OWENB001` and no `OWN002`. + - **Why:** treating a declared but missing descriptor as "no extension" would be fail-open, so it is an error. The contract (§ *Extension discovery*) already says a descriptor that cannot be read is `OWENB002`; the registered M1 expectation contradicted it. +2. **The `Owen.Cli` payload criterion is restated.** "File-for-file identical" is not achievable for the two assemblies built from this repository. `ownsharp.dll` gains `build-check` by design. `ownsharp-extract.dll` carries commit-stamped build metadata although its source is unchanged: the bytes differed between two packs of the same extractor source from two commits, even under `ContinuousIntegrationBuild`. + + The criterion is now: + - an **identical file list**; + - **every other payload file byte-identical**: Roslyn, `deps.json`/`runtimeconfig.json`, vendored `.py`, Rust core; + - `git diff 88cb8cc3..HEAD -- frontend/roslyn/OwnSharp.Extractor` **empty**; + - gate A (install → `owen check` → `OWN001`) passing on the head. +3. **G: the acceptance runner builds with `-p:OwenEnabled=false`.** + - **Why the host runs there at all:** the runner references the consumer project, so the host reaches it through `buildTransitive`, as it reaches any dependent project. + - **What the host does there:** the runner opens a protocol region in **top-level statements**, which the extractor does not model, so the host refuses it with `OWENB010`. That is fail-closed behaviour, not a defect. + - **Why switching it off is acceptable:** the runner is a test harness, not the product under test. The consumer itself is analysed by A–F and H. + + The registered criterion (44/44, transcript byte-identical) is unchanged. +4. **The host runs with `DOTNET_ROLL_FORWARD=Major`,** set on the Exec. The host payload targets .NET 8, and a machine with only a newer SDK has no .NET 8 runtime. The isolated consumer keeps the registered `--framework net8.0`. + ## P0 results Run on `88cb8cc31c49d730290613208947102d0737cad2`, a clean tree except for the two documents of this commit: diff --git a/frontend/roslyn/Owen.Build/Owen.Build.csproj b/frontend/roslyn/Owen.Build/Owen.Build.csproj new file mode 100644 index 00000000..47d24a94 --- /dev/null +++ b/frontend/roslyn/Owen.Build/Owen.Build.csproj @@ -0,0 +1,75 @@ + + + + + + netstandard2.0 + false + $(NoWarn);NU5128;NU5100 + true + Owen.Build + + 0.1.0 + Owen build host: runs Owen (the Roslyn extractor and the Rust analysis core it ships) on every build for the Owen extensions a project references, and reports findings as ordinary build diagnostics. + PhysShell + https://github.com/PhysShell/Own.NET + https://github.com/PhysShell/Own.NET + git + owen;static-analysis;msbuild;ownership;lifetime + README.md + false + false + + + + + + + + + + + + + + <_OwenHostStage>$(MSBuildProjectDirectory)/$(BaseIntermediateOutputPath)host-stage/ + + + + + + + + + + + + + <_OwenHostFiles Include="$(_OwenHostStage)**/*" Exclude="$(_OwenHostStage)*.py;$(_OwenHostStage)**/*.pdb;$(_OwenHostStage)ownsharp;$(_OwenHostStage)ownsharp-extract;$(_OwenHostStage)*.exe" /> + + + + + + diff --git a/frontend/roslyn/Owen.Build/README.md b/frontend/roslyn/Owen.Build/README.md new file mode 100644 index 00000000..79f91e3d --- /dev/null +++ b/frontend/roslyn/Owen.Build/README.md @@ -0,0 +1,20 @@ +# Owen.Build + +The Owen build host. You normally do not reference it yourself: the Owen extensions you +reference depend on it. + +On every `dotnet build`, after compilation, it runs Owen once for every active Owen +extension: +- the Roslyn extractor and the Rust analysis core, both shipped inside this package; +- no Python, no Rust toolchain, no global tool; +- findings are reported as ordinary build diagnostics (`file(line): warning OWNxxx: …`), + including in Visual Studio's Error List. + +| property | default | meaning | +|---|---|---| +| `OwenSeverity` | `warning` | `error` makes findings fail the build | +| `OwenEnabled` | `true` | `false` turns the host off | + +Each project's active extensions are listed in `obj/owen/extensions.json`. Host errors use +the `OWENB` codes; see `spec/OwenExtension.md` in the Owen repository. Platforms: `linux-x64` +and `win-x64`. diff --git a/frontend/roslyn/Owen.Build/build/Owen.Build.props b/frontend/roslyn/Owen.Build/build/Owen.Build.props new file mode 100644 index 00000000..81cfe9f9 --- /dev/null +++ b/frontend/roslyn/Owen.Build/build/Owen.Build.props @@ -0,0 +1,15 @@ + + + + + true + + warning + + true + + diff --git a/frontend/roslyn/Owen.Build/build/Owen.Build.targets b/frontend/roslyn/Owen.Build/build/Owen.Build.targets new file mode 100644 index 00000000..d7b3638c --- /dev/null +++ b/frontend/roslyn/Owen.Build/build/Owen.Build.targets @@ -0,0 +1,60 @@ + + + + + <_OwenHostDir>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', 'tools', 'net8.0', 'any')) + <_OwenObjDir>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)', '$(BaseIntermediateOutputPath)', 'owen')) + + + + + + <_OwenDotnet>$(DOTNET_HOST_PATH) + <_OwenDotnet Condition="'$(_OwenDotnet)' == ''">dotnet + <_OwenGeneratedRoot>$(CompilerGeneratedFilesOutputPath) + <_OwenGeneratedRoot Condition="'$(_OwenGeneratedRoot)' != '' and !$([System.IO.Path]::IsPathRooted('$(_OwenGeneratedRoot)'))">$(MSBuildProjectDirectory)/$(_OwenGeneratedRoot) + <_OwenRequest>$(_OwenObjDir)request.txt + + + <_OwenRequestLine Include="project $(MSBuildProjectFullPath)" /> + <_OwenRequestLine Include="@(OwenExtensionDescriptor->'descriptor %(FullPath)')" /> + <_OwenRequestLine Include="generated-root $(_OwenGeneratedRoot)" Condition="'$(_OwenGeneratedRoot)' != ''" /> + <_OwenRequestLine Include="severity $(OwenSeverity)" /> + <_OwenRequestLine Include="manifest $(_OwenObjDir)extensions.json" /> + <_OwenRequestLine Include="emit-facts $(OwenEmitFacts)" Condition="'$(OwenEmitFacts)' != ''" /> + + + + + + + + + + + diff --git a/frontend/roslyn/Owen.TypedBuilder/Owen.TypedBuilder.csproj b/frontend/roslyn/Owen.TypedBuilder/Owen.TypedBuilder.csproj new file mode 100644 index 00000000..f0cba0bc --- /dev/null +++ b/frontend/roslyn/Owen.TypedBuilder/Owen.TypedBuilder.csproj @@ -0,0 +1,65 @@ + + + + + netstandard2.0 + 12 + enable + Owen.TypedBuilder.Generator + Own.TypedBuilder + true + true + true + true + + true + Owen.TypedBuilder + 0.1.0 + Owen extension: typed states and a typed builder generated from one annotated entity, checked on every build by Owen (ownership, state protocols, proven harmless calls). + PhysShell + https://github.com/PhysShell/Own.NET + https://github.com/PhysShell/Own.NET + git + roslyn;source-generator;typestate;ef-core;owen + README.md + + false + false + false + + $(NoWarn);NU5128;RS2008 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/roslyn/Owen.TypedBuilder/Polyfills.cs b/frontend/roslyn/Owen.TypedBuilder/Polyfills.cs new file mode 100644 index 00000000..21c4bf62 --- /dev/null +++ b/frontend/roslyn/Owen.TypedBuilder/Polyfills.cs @@ -0,0 +1,7 @@ +// netstandard2.0 lacks the marker type C# needs for `record` and `init`. +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit + { + } +} diff --git a/frontend/roslyn/Owen.TypedBuilder/README.md b/frontend/roslyn/Owen.TypedBuilder/README.md new file mode 100644 index 00000000..f56e20a9 --- /dev/null +++ b/frontend/roslyn/Owen.TypedBuilder/README.md @@ -0,0 +1,32 @@ +# Owen.TypedBuilder + +Typed states and a typed builder for an ordinary entity, generated from one annotated +declaration and checked by Owen on every build. + +``` +dotnet add package Owen.TypedBuilder +``` + +```csharp +public enum OrderStatus { Draft, Submitted, Approved, Shipped } + +[TypedProtocol] +public sealed partial class Order +{ + private Order() { } + public int Id { get; private set; } + [BuilderRequired] public string Customer { get; private set; } = ""; + [ProtocolState] public OrderStatus Status { get; private set; } + + [Transition("Submit", OrderStatus.Draft, OrderStatus.Submitted)] + private void OnSubmit(DateTime at) { } +} +``` + +The generator writes `DraftOrder.Submit(…)`, `OrderProtocol.WithDraft(order, draft => …)` +and `Order.Create().Customer(…).Build()`. What it catches, and where: +- an illegal transition (`draft.Approve()`) is a compiler error; +- a stale or copied state, or the raw entity touched while a state is open, is an Owen + finding reported by the build (`OWN002`, `OWN005`, `OWN013`), through `Owen.Build`. + +A full example is in `samples/OrderBackend` in the Owen repository. diff --git a/frontend/roslyn/Owen.TypedBuilder/TypedBuilderGenerator.cs b/frontend/roslyn/Owen.TypedBuilder/TypedBuilderGenerator.cs new file mode 100644 index 00000000..a4bda556 --- /dev/null +++ b/frontend/roslyn/Owen.TypedBuilder/TypedBuilderGenerator.cs @@ -0,0 +1,152 @@ +// The Typed Builder as a Roslyn incremental generator (OX-01, generator delivery A). +// +// For every source file that declares a [TypedProtocol] class, it runs the SAME model and +// renderer as the CLI (TypedBuilderCore.cs, linked) over that file's text and adds the result +// to the compilation. So the typed API exists in every compilation — `dotnet build`, the +// IDE's live one, a design-time build — with no build step of its own (preregistration K-3). +// +// The one difference from the CLI's bytes is a first line `#nullable enable`: a generated +// source compiles with nullable annotations off unless it says otherwise, and the rendered +// protocol uses `string?`. The rendered text after that line is the CLI's, byte for byte. +// +// A declaration the model refuses is a compiler ERROR (OWENTB001, one per defect), never an +// empty output that would leave the protocol silently missing. + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace Own.TypedBuilder +{ + [Generator(LanguageNames.CSharp)] + public sealed class TypedBuilderGenerator : IIncrementalGenerator + { + private static readonly DiagnosticDescriptor Refused = new( + id: "OWENTB001", + title: "Typed Builder declaration refused", + messageFormat: "{0}", + category: "Owen.TypedBuilder", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static post => + post.AddSource("Owen.TypedBuilder.Attributes.g.cs", SourceText.From(Attributes, Encoding.UTF8))); + + var declarations = context.SyntaxProvider + .CreateSyntaxProvider( + static (node, _) => node is ClassDeclarationSyntax c && IsTypedProtocol(c), + static (ctx, _) => ctx.Node.SyntaxTree) + .Collect(); + + context.RegisterSourceOutput(declarations, static (spc, trees) => + { + var hints = new HashSet(System.StringComparer.OrdinalIgnoreCase); + foreach (var tree in trees.Distinct().OrderBy(t => t.FilePath, System.StringComparer.Ordinal)) + { + var file = Path.GetFileName(tree.FilePath); + // exactly the CLI's input: the file's text with \r\n folded to \n + var text = tree.GetText(spc.CancellationToken).ToString().Replace("\r\n", "\n"); + var root = CSharpSyntaxTree.ParseText(text, cancellationToken: spc.CancellationToken) + .GetRoot(spc.CancellationToken); + var problems = new List(); + var model = Model.Read(root, file, problems); + if (model is null || problems.Count > 0) + { + var where = FirstDeclaration(tree); + foreach (var problem in problems) + spc.ReportDiagnostic(Diagnostic.Create(Refused, where, $"{file}: {problem}")); + continue; + } + var stem = Path.GetFileNameWithoutExtension(file); + var hint = stem + ".Protocol.g.cs"; + for (var n = 2; !hints.Add(hint); n++) + hint = $"{stem}.{n}.Protocol.g.cs"; + spc.AddSource(hint, SourceText.From("#nullable enable\n" + Emit.Render(model), Encoding.UTF8)); + } + }); + } + + private static bool IsTypedProtocol(ClassDeclarationSyntax c) => + c.AttributeLists.SelectMany(l => l.Attributes).Any(a => + { + var name = a.Name switch + { + QualifiedNameSyntax q => q.Right.Identifier.Text, + AliasQualifiedNameSyntax q => q.Name.Identifier.Text, + SimpleNameSyntax s => s.Identifier.Text, + _ => a.Name.ToString(), + }; + return name is "TypedProtocol" or "TypedProtocolAttribute"; + }); + + private static Location FirstDeclaration(SyntaxTree tree) + { + var c = tree.GetRoot().DescendantNodes().OfType().FirstOrDefault(IsTypedProtocol); + return c is null ? Location.None : c.Identifier.GetLocation(); + } + + // The vocabulary, matched by NAME by both this generator and the Owen extractor. Emitted + // into the consumer's own compilation as internal types in the global namespace, so a + // declaration needs no `using`, the package adds no runtime assembly, and the rendered + // protocol (which names [ProtocolToken] / [ProtocolRegion] unqualified) is unchanged. + private const string Attributes = @"// Generated by Owen.TypedBuilder. The Typed Builder vocabulary, matched by name. +#nullable enable + +/// The entity whose state protocol is generated. A `partial class`. +[global::System.AttributeUsage(global::System.AttributeTargets.Class)] +internal sealed class TypedProtocolAttribute : global::System.Attribute +{ +} + +/// The one property that holds the state. Its enum's first member is the initial state. +[global::System.AttributeUsage(global::System.AttributeTargets.Property)] +internal sealed class ProtocolStateAttribute : global::System.Attribute +{ +} + +/// A field the builder demands before Build() exists. +[global::System.AttributeUsage(global::System.AttributeTargets.Property)] +internal sealed class BuilderRequiredAttribute : global::System.Attribute +{ +} + +/// A transition: its name, the state it leaves, the state it enters. +[global::System.AttributeUsage(global::System.AttributeTargets.Method)] +internal sealed class TransitionAttribute : global::System.Attribute +{ + public TransitionAttribute(string name, object from, object to) + { + Name = name; + From = from; + To = to; + } + + public string Name { get; } + + public object From { get; } + + public object To { get; } +} + +/// A state: a ref struct over the entity (Owen state-protocol profile). +[global::System.AttributeUsage(global::System.AttributeTargets.Struct)] +internal sealed class ProtocolTokenAttribute : global::System.Attribute +{ +} + +/// A region entry: (entity, callback) (Owen state-protocol profile). +[global::System.AttributeUsage(global::System.AttributeTargets.Method)] +internal sealed class ProtocolRegionAttribute : global::System.Attribute +{ +} +"; + } +} diff --git a/frontend/roslyn/Owen.TypedBuilder/buildTransitive/Owen.TypedBuilder.props b/frontend/roslyn/Owen.TypedBuilder/buildTransitive/Owen.TypedBuilder.props new file mode 100644 index 00000000..475a3dd4 --- /dev/null +++ b/frontend/roslyn/Owen.TypedBuilder/buildTransitive/Owen.TypedBuilder.props @@ -0,0 +1,8 @@ + + + + + + diff --git a/frontend/roslyn/Owen.TypedBuilder/buildTransitive/owen-extension.json b/frontend/roslyn/Owen.TypedBuilder/buildTransitive/owen-extension.json new file mode 100644 index 00000000..ffbf7dd2 --- /dev/null +++ b/frontend/roslyn/Owen.TypedBuilder/buildTransitive/owen-extension.json @@ -0,0 +1,13 @@ +{ + "owen_extension": 1, + "id": "Owen.TypedBuilder", + "version": "0.1.0", + "requires": { + "host": "0.1.0", + "ownir": 2, + "capabilities": ["ownership", "state-protocol", "heap-effects", "proven-call"] + }, + "frontend": { + "generators": ["Owen.TypedBuilder.Generator"] + } +} diff --git a/frontend/roslyn/OwenRustCore.props b/frontend/roslyn/OwenRustCore.props new file mode 100644 index 00000000..86f0599b --- /dev/null +++ b/frontend/roslyn/OwenRustCore.props @@ -0,0 +1,40 @@ + + + + linux-x64;win-x64 + + + + + + + + + + <_OwenRustCoreStaged Include="@(OwenRustCoreFiles->'%(RecursiveDir)%(Filename)%(Extension)'->Replace('\', '/'))" /> + <_OwenRustCoreExpected Include="$(OwenRustCorePlatforms)" /> + <_OwenRustCoreAllowed Include="@(_OwenRustCoreExpected->'%(Identity)/own-cli')" Condition="!$([System.String]::Copy('%(Identity)').StartsWith('win-'))" /> + <_OwenRustCoreAllowed Include="@(_OwenRustCoreExpected->'%(Identity)/own-cli.exe')" Condition="$([System.String]::Copy('%(Identity)').StartsWith('win-'))" /> + <_OwenRustCoreUnknown Include="@(_OwenRustCoreStaged)" Exclude="@(_OwenRustCoreAllowed)" /> + + + + + + diff --git a/frontend/roslyn/Own.TypedBuilder/Program.cs b/frontend/roslyn/Own.TypedBuilder/Program.cs index 7bb62f2e..4a154458 100644 --- a/frontend/roslyn/Own.TypedBuilder/Program.cs +++ b/frontend/roslyn/Own.TypedBuilder/Program.cs @@ -32,7 +32,7 @@ using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using Own.TypedBuilder; if (args.Length != 3 || args[1] != "-o") { @@ -59,406 +59,3 @@ var output = Emit.Render(model); File.WriteAllBytes(args[2], new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(output)); return 0; - -/// One transition as declared: `[Transition(Name, From, To)]` on `Hook(Parameters)`. -sealed record Transition(string Name, string From, string To, string Hook, IReadOnlyList<(string Type, string Name)> Parameters); - -/// One `[BuilderRequired]` construction field. -sealed record Field(string Name, string Type, bool NullCheck); - -sealed record Model( - string Source, - string? Namespace, - string Entity, - string IdType, - string StateProperty, - string StateEnum, - IReadOnlyList States, - IReadOnlyList Fields, - IReadOnlyList Transitions) -{ - static bool Named(AttributeSyntax a, string name) - { - var n = a.Name switch - { - QualifiedNameSyntax q => q.Right.Identifier.Text, - AliasQualifiedNameSyntax q => q.Name.Identifier.Text, - SimpleNameSyntax s => s.Identifier.Text, - _ => a.Name.ToString(), - }; - return n == name || n == name + "Attribute"; - } - - static IEnumerable Attrs(MemberDeclarationSyntax m) => - m.AttributeLists.SelectMany(l => l.Attributes); - - static readonly HashSet ValueKeywords = new(StringComparer.Ordinal) - { - "bool", "byte", "sbyte", "short", "ushort", "int", "uint", "long", "ulong", - "decimal", "double", "float", "char", - }; - - public static Model? Read(SyntaxNode root, string source, List problems) - { - var entities = root.DescendantNodes().OfType() - .Where(c => Attrs(c).Any(a => Named(a, "TypedProtocol"))).ToList(); - if (entities.Count != 1) - { - problems.Add($"expected exactly one [TypedProtocol] class, found {entities.Count}"); - return null; - } - var entity = entities[0]; - if (!entity.Modifiers.Any(SyntaxKind.PartialKeyword)) - problems.Add($"'{entity.Identifier.Text}' must be a partial class: the generated half is the other part"); - if (entity.TypeParameterList is not null || entity.Parent is TypeDeclarationSyntax) - problems.Add($"'{entity.Identifier.Text}' must be a top-level, non-generic class"); - - var ns = entity.Ancestors().OfType().FirstOrDefault()?.Name.ToString(); - - var enums = root.DescendantNodes().OfType() - .ToDictionary(e => e.Identifier.Text, e => e.Members.Select(m => m.Identifier.Text).ToList()); - - var properties = entity.Members.OfType().ToList(); - var id = properties.FirstOrDefault(p => p.Identifier.Text == "Id"); - if (id is null) - problems.Add($"'{entity.Identifier.Text}' has no 'Id' property: the tokens and the refusal name the entity by it"); - - var stateProps = properties.Where(p => Attrs(p).Any(a => Named(a, "ProtocolState"))).ToList(); - string stateEnum = "", stateProp = ""; - List states = new(); - if (stateProps.Count != 1) - problems.Add($"expected exactly one [ProtocolState] property, found {stateProps.Count}"); - else - { - stateProp = stateProps[0].Identifier.Text; - stateEnum = stateProps[0].Type.ToString(); - if (!enums.TryGetValue(stateEnum, out states!)) - { - problems.Add($"the state property '{stateProp}' has type '{stateEnum}', which is not an enum declared in this file"); - states = new(); - } - else if (states.Count == 0) - problems.Add($"the state enum '{stateEnum}' has no members"); - else if (states.Distinct(StringComparer.Ordinal).Count() != states.Count) - problems.Add($"the state enum '{stateEnum}' declares a member twice"); - if (PubliclyWritable(stateProps[0])) - problems.Add($"the state property '{stateProp}' must not be publicly writable: the tokens are the only way to change it"); - } - - var fields = new List(); - foreach (var p in properties.Where(p => Attrs(p).Any(a => Named(a, "BuilderRequired")))) - { - var type = p.Type.ToString(); - if (type == "string") - fields.Add(new Field(p.Identifier.Text, type, NullCheck: true)); - else if (ValueKeywords.Contains(type)) - fields.Add(new Field(p.Identifier.Text, type, NullCheck: false)); - else - problems.Add($"[BuilderRequired] '{p.Identifier.Text}' has type '{type}': only string and the built-in value types are supported"); - if (p.Identifier.Text == stateProp) - problems.Add($"[BuilderRequired] '{p.Identifier.Text}' is the state: Build() sets it"); - } - - var transitions = new List(); - foreach (var m in entity.Members.OfType()) - foreach (var a in Attrs(m).Where(a => Named(a, "Transition"))) - { - var where = $"[Transition] on '{m.Identifier.Text}'"; - var argsList = a.ArgumentList?.Arguments ?? default; - if (argsList.Count != 3 || argsList.Any(x => x.NameEquals is not null || x.NameColon is not null)) - { - problems.Add($"{where}: expected (\"Name\", {stateEnum}.From, {stateEnum}.To)"); - continue; - } - if (argsList[0].Expression is not LiteralExpressionSyntax lit || !lit.IsKind(SyntaxKind.StringLiteralExpression) - || !SyntaxFacts.IsValidIdentifier(lit.Token.ValueText)) - { - problems.Add($"{where}: the name must be a string literal that is a C# identifier"); - continue; - } - string? State(ExpressionSyntax e) - { - if (e is MemberAccessExpressionSyntax { Expression: IdentifierNameSyntax owner } ma - && owner.Identifier.Text == stateEnum && states.Contains(ma.Name.Identifier.Text)) - return ma.Name.Identifier.Text; - problems.Add($"{where}: '{e}' is not a member of the state enum '{stateEnum}'"); - return null; - } - var from = State(argsList[1].Expression); - var to = State(argsList[2].Expression); - if (m.Modifiers.Any(SyntaxKind.PublicKeyword)) - problems.Add($"{where}: the hook must not be public: a public method writing protocol data is a transition nobody declared"); - if (m.Modifiers.Any(SyntaxKind.StaticKeyword) || m.TypeParameterList is not null - || m.ReturnType.ToString() != "void" || m.Body is null && m.ExpressionBody is null) - problems.Add($"{where}: the hook must be a non-generic instance method returning void, with a body"); - var ps = new List<(string, string)>(); - foreach (var p in m.ParameterList.Parameters) - { - if (p.Modifiers.Count > 0 || p.Default is not null || p.Type is null) - problems.Add($"{where}: parameter '{p.Identifier.Text}' must be a plain by-value parameter with no default"); - ps.Add((p.Type?.ToString() ?? "?", p.Identifier.Text)); - } - if (from is not null && to is not null) - transitions.Add(new Transition(lit.Token.ValueText, from, to, m.Identifier.Text, ps)); - } - if (transitions.Count == 0) - problems.Add("no [Transition] is declared"); - foreach (var dup in transitions.GroupBy(t => t.Name).Where(g => g.Count() > 1)) - problems.Add($"the transition name '{dup.Key}' is declared {dup.Count()} times"); - foreach (var t in transitions) - if (t.Name == "Id") - problems.Add("a transition may not be named 'Id': the tokens expose the entity's Id"); - - return new Model(source, ns, entity.Identifier.Text, id?.Type.ToString() ?? "int", stateProp, stateEnum, - states, fields, transitions); - } - - static bool PubliclyWritable(PropertyDeclarationSyntax p) - { - if (!p.Modifiers.Any(SyntaxKind.PublicKeyword)) - return false; - var setter = p.AccessorList?.Accessors.FirstOrDefault(a => a.IsKind(SyntaxKind.SetAccessorDeclaration)); - return setter is not null && setter.Modifiers.Count == 0; - } -} - -static class Emit -{ - public static string Render(Model m) - { - var o = new StringBuilder(); - void L(string line = "") => o.Append(line).Append('\n'); - string Token(string state) => state + m.Entity; - var outgoing = m.States.Where(s => m.Transitions.Any(t => t.From == s)).ToList(); - var e = m.Entity; - var lower = char.ToLowerInvariant(e[0]) + e[1..]; - var invalid = $"Invalid{e}StateException"; - var corrupt = $"Corrupt{e}StateException"; - var initial = m.States[0]; - - L($"// Generated by Own.TypedBuilder from {m.Source}. Do not edit: change {m.Source} and regenerate."); - L("//"); - L("// The state protocol of " + e + ": one [ProtocolToken] per state, one transition per"); - L("// [Transition], one [ProtocolRegion] per state with a way out, the checked refinement,"); - L("// the strict storage of the state, and the staged builder. Own.NET analyses this file as"); - L("// source: it is the protocol's trusted definition surface."); - L(); - L("using System;"); - L(); - if (m.Namespace is not null) - { - L($"namespace {m.Namespace};"); - L(); - } - - // ---- the entity's generated half -------------------------------------------------- - L($"partial class {e}"); - L("{"); - foreach (var t in m.Transitions) - { - var ps = string.Join(", ", t.Parameters.Select(p => $"{p.Type} {p.Name}")); - var args = string.Join(", ", t.Parameters.Select(p => p.Name)); - L($" // {t.From} -> {t.Name} -> {t.To}"); - L($" internal void Apply{t.Name}({ps})"); - L(" {"); - L($" {t.Hook}({args});"); - L($" {m.StateProperty} = {m.StateEnum}.{t.To};"); - L(" }"); - L(); - } - // the first step is the innermost one (see EmitBuilder) - var first = string.Join(".", m.Fields.Select(f => f.Name + "Step").Reverse().Prepend($"{initial}Builder")); - L($" /// Starts a new {e} in its initial state, {initial}. Build() exists only once every"); - L(" /// required field was given."); - L($" public static {first} Create() => new();"); - L(); - EmitBuilder(m, L, initial); - L("}"); - L(); - - // ---- refusals --------------------------------------------------------------------- - L($"/// The runtime half of the refinement: the state came from data, so it is checked once,"); - L("/// at the region entry."); - L($"public sealed class {invalid}({m.IdType} id, {m.StateEnum} actual, {m.StateEnum} required)"); - L($" : InvalidOperationException($\"{lower} {{id}} is {{actual}}, not {{required}}\")"); - L("{"); - L($" public {m.IdType} Id {{ get; }} = id;"); - L(); - L($" public {m.StateEnum} Actual {{ get; }} = actual;"); - L(); - L($" public {m.StateEnum} Required {{ get; }} = required;"); - L("}"); - L(); - L($"/// A persisted state that is not exactly one of {m.StateEnum}'s names. Never mapped to a"); - L("/// state: no default, no case folding, no number."); - L($"public sealed class {corrupt}(string raw)"); - L($" : InvalidOperationException($\"the persisted {lower} state '{{raw}}' is not {Article(m.StateEnum)} {m.StateEnum}\")"); - L("{"); - L(" public string Raw { get; } = raw;"); - L("}"); - L(); - - // ---- strict storage --------------------------------------------------------------- - L($"/// The one mapping between {m.StateEnum} and its stored text: the exact member name."); - L($"public static class {m.StateEnum}Storage"); - L("{"); - L($" public static string ToStore({m.StateEnum} state) => state switch"); - L(" {"); - foreach (var s in m.States) - L($" {m.StateEnum}.{s} => \"{s}\","); - L($" _ => throw new {corrupt}(state.ToString()),"); - L(" };"); - L(); - L($" public static {m.StateEnum} FromStore(string raw) =>"); - L($" TryFromStore(raw, out var state) ? state : throw new {corrupt}(raw);"); - L(); - L($" public static bool TryFromStore(string? raw, out {m.StateEnum} state)"); - L(" {"); - L(" switch (raw)"); - L(" {"); - foreach (var s in m.States) - { - L($" case \"{s}\":"); - L($" state = {m.StateEnum}.{s};"); - L(" return true;"); - } - L(" default:"); - L(" state = default;"); - L(" return false;"); - L(" }"); - L(" }"); - L("}"); - L(); - - // ---- tokens ----------------------------------------------------------------------- - foreach (var s in m.States) - { - var exits = m.Transitions.Where(t => t.From == s).ToList(); - L(exits.Count == 0 - ? $"/// {e} in state {s}. Terminal: no transition leaves it." - : $"/// {e} in state {s}. A transition spends this token and hands back the next one."); - L("[ProtocolToken]"); - L($"public readonly ref struct {Token(s)}"); - L("{"); - L($" private readonly {e} _{lower};"); - L(); - L($" internal {Token(s)}({e} {lower}) => _{lower} = {lower};"); - L(); - L($" public {m.IdType} Id => _{lower}.Id;"); - foreach (var t in exits) - { - var ps = string.Join(", ", t.Parameters.Select(p => $"{p.Type} {p.Name}")); - var args = string.Join(", ", t.Parameters.Select(p => p.Name)); - L(); - L($" public {Token(t.To)} {t.Name}({ps})"); - L(" {"); - L($" _{lower}.Apply{t.Name}({args});"); - L($" return new {Token(t.To)}(_{lower});"); - L(" }"); - } - L("}"); - L(); - } - - // ---- regions ---------------------------------------------------------------------- - foreach (var s in outgoing) - L($"public delegate void {s}Region({Token(s)} {char.ToLowerInvariant(s[0]) + s[1..]});"); - L(); - L($"/// The checked refinement: {Article(e)} {e} whose state is only known at run time becomes a token"); - L("/// inside the callback, or the call throws and no token exists. A state no transition"); - L("/// leaves has no region: its token could never be spent."); - L($"public static class {e}Protocol"); - L("{"); - for (var i = 0; i < outgoing.Count; i++) - { - var s = outgoing[i]; - if (i > 0) - L(); - L(" [ProtocolRegion]"); - L($" public static void With{s}({e} {lower}, {s}Region body)"); - L(" {"); - L($" ArgumentNullException.ThrowIfNull({lower});"); - L(" ArgumentNullException.ThrowIfNull(body);"); - L($" if ({lower}.{m.StateProperty} != {m.StateEnum}.{s})"); - L($" throw new {invalid}({lower}.Id, {lower}.{m.StateProperty}, {m.StateEnum}.{s});"); - L($" body(new {Token(s)}({lower}));"); - L(" }"); - } - L("}"); - return o.ToString(); - } - - static string Article(string word) => "AEIOUaeiou".Contains(word[0]) ? "an" : "a"; - - // The staged builder, nested inside the entity so it can reach its private constructor - // and setters. The steps nest INWARD from the finished builder: each step constructs the - // type that contains it through a private constructor, so the only way to a Build() is - // through every step, in order. - static void EmitBuilder(Model m, Action L, string initial) - { - var e = m.Entity; - var depth = 1; - string Pad() => new(' ', depth * 4); - string Lower(string s) => char.ToLowerInvariant(s[0]) + s[1..]; - - L($"{Pad()}public sealed class {initial}Builder"); - L($"{Pad()}{{"); - depth++; - foreach (var f in m.Fields) - L($"{Pad()}private readonly {f.Type} _{Lower(f.Name)};"); - if (m.Fields.Count > 0) - L(""); - var all = string.Join(", ", m.Fields.Select(f => $"{f.Type} {Lower(f.Name)}")); - L($"{Pad()}{(m.Fields.Count == 0 ? "internal" : "private")} {initial}Builder({all})"); - L($"{Pad()}{{"); - foreach (var f in m.Fields) - L($"{Pad()} _{Lower(f.Name)} = {Lower(f.Name)};"); - L($"{Pad()}}}"); - L(""); - var init = string.Join(", ", m.Fields.Select(f => $"{f.Name} = _{Lower(f.Name)}") - .Append($"{m.StateProperty} = {m.StateEnum}.{initial}")); - L($"{Pad()}public {e} Build() => new() {{ {init} }};"); - - // step k holds fields [0, k) and takes field k; the steps nest so that step k sits - // inside step k+1 (the last step inside the builder). - var opened = 0; - for (var k = m.Fields.Count - 1; k >= 0; k--) - { - var f = m.Fields[k]; - var held = m.Fields.Take(k).ToList(); - var next = k == m.Fields.Count - 1 ? $"{initial}Builder" : $"{m.Fields[k + 1].Name}Step"; - L(""); - L($"{Pad()}public sealed class {f.Name}Step"); - L($"{Pad()}{{"); - depth++; - foreach (var h in held) - L($"{Pad()}private readonly {h.Type} _{Lower(h.Name)};"); - if (held.Count > 0) - L(""); - var ctor = string.Join(", ", held.Select(h => $"{h.Type} {Lower(h.Name)}")); - // the first step is where Create() starts: it holds nothing, so making one by - // hand gains nothing - L($"{Pad()}{(k == 0 ? "internal" : "private")} {f.Name}Step({ctor})"); - L($"{Pad()}{{"); - foreach (var h in held) - L($"{Pad()} _{Lower(h.Name)} = {Lower(h.Name)};"); - L($"{Pad()}}}"); - L(""); - var pass = string.Join(", ", held.Select(h => $"_{Lower(h.Name)}").Append(Lower(f.Name))); - L($"{Pad()}public {next} {f.Name}({f.Type} {Lower(f.Name)})"); - L($"{Pad()}{{"); - if (f.NullCheck) - L($"{Pad()} ArgumentNullException.ThrowIfNull({Lower(f.Name)});"); - L($"{Pad()} return new {next}({pass});"); - L($"{Pad()}}}"); - opened++; - } - for (var i = 0; i < opened; i++) - { - depth--; - L($"{Pad()}}}"); - } - depth--; - L($"{Pad()}}}"); - } -} diff --git a/frontend/roslyn/Own.TypedBuilder/TypedBuilderCore.cs b/frontend/roslyn/Own.TypedBuilder/TypedBuilderCore.cs new file mode 100644 index 00000000..58d8ea24 --- /dev/null +++ b/frontend/roslyn/Own.TypedBuilder/TypedBuilderCore.cs @@ -0,0 +1,423 @@ +// The Typed Builder model and renderer, shared VERBATIM by the two front doors: +// +// * frontend/roslyn/Own.TypedBuilder — the CLI (`own-typed-builder -o …`); +// * frontend/roslyn/Owen.TypedBuilder — the Roslyn incremental generator inside the +// `Owen.TypedBuilder` package (links this file). +// +// One source, one output: whichever door runs it, the same declaration renders the same +// text (the generator only prepends `#nullable enable`, see TypedBuilderGenerator.cs). +// Written for netstandard2.0 as well as net8.0: no ranges, no newer BCL APIs. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Own.TypedBuilder +{ + /// One transition as declared: `[Transition(Name, From, To)]` on `Hook(Parameters)`. + sealed record Transition(string Name, string From, string To, string Hook, IReadOnlyList<(string Type, string Name)> Parameters); + + /// One `[BuilderRequired]` construction field. + sealed record Field(string Name, string Type, bool NullCheck); + + sealed record Model( + string Source, + string? Namespace, + string Entity, + string IdType, + string StateProperty, + string StateEnum, + IReadOnlyList States, + IReadOnlyList Fields, + IReadOnlyList Transitions) + { + static bool Named(AttributeSyntax a, string name) + { + var n = a.Name switch + { + QualifiedNameSyntax q => q.Right.Identifier.Text, + AliasQualifiedNameSyntax q => q.Name.Identifier.Text, + SimpleNameSyntax s => s.Identifier.Text, + _ => a.Name.ToString(), + }; + return n == name || n == name + "Attribute"; + } + + static IEnumerable Attrs(MemberDeclarationSyntax m) => + m.AttributeLists.SelectMany(l => l.Attributes); + + static readonly HashSet ValueKeywords = new(StringComparer.Ordinal) + { + "bool", "byte", "sbyte", "short", "ushort", "int", "uint", "long", "ulong", + "decimal", "double", "float", "char", + }; + + public static Model? Read(SyntaxNode root, string source, List problems) + { + var entities = root.DescendantNodes().OfType() + .Where(c => Attrs(c).Any(a => Named(a, "TypedProtocol"))).ToList(); + if (entities.Count != 1) + { + problems.Add($"expected exactly one [TypedProtocol] class, found {entities.Count}"); + return null; + } + var entity = entities[0]; + if (!entity.Modifiers.Any(SyntaxKind.PartialKeyword)) + problems.Add($"'{entity.Identifier.Text}' must be a partial class: the generated half is the other part"); + if (entity.TypeParameterList is not null || entity.Parent is TypeDeclarationSyntax) + problems.Add($"'{entity.Identifier.Text}' must be a top-level, non-generic class"); + + var ns = entity.Ancestors().OfType().FirstOrDefault()?.Name.ToString(); + + var enums = root.DescendantNodes().OfType() + .ToDictionary(e => e.Identifier.Text, e => e.Members.Select(m => m.Identifier.Text).ToList()); + + var properties = entity.Members.OfType().ToList(); + var id = properties.FirstOrDefault(p => p.Identifier.Text == "Id"); + if (id is null) + problems.Add($"'{entity.Identifier.Text}' has no 'Id' property: the tokens and the refusal name the entity by it"); + + var stateProps = properties.Where(p => Attrs(p).Any(a => Named(a, "ProtocolState"))).ToList(); + string stateEnum = "", stateProp = ""; + List states = new(); + if (stateProps.Count != 1) + problems.Add($"expected exactly one [ProtocolState] property, found {stateProps.Count}"); + else + { + stateProp = stateProps[0].Identifier.Text; + stateEnum = stateProps[0].Type.ToString(); + if (!enums.TryGetValue(stateEnum, out states!)) + { + problems.Add($"the state property '{stateProp}' has type '{stateEnum}', which is not an enum declared in this file"); + states = new(); + } + else if (states.Count == 0) + problems.Add($"the state enum '{stateEnum}' has no members"); + else if (states.Distinct(StringComparer.Ordinal).Count() != states.Count) + problems.Add($"the state enum '{stateEnum}' declares a member twice"); + if (PubliclyWritable(stateProps[0])) + problems.Add($"the state property '{stateProp}' must not be publicly writable: the tokens are the only way to change it"); + } + + var fields = new List(); + foreach (var p in properties.Where(p => Attrs(p).Any(a => Named(a, "BuilderRequired")))) + { + var type = p.Type.ToString(); + if (type == "string") + fields.Add(new Field(p.Identifier.Text, type, NullCheck: true)); + else if (ValueKeywords.Contains(type)) + fields.Add(new Field(p.Identifier.Text, type, NullCheck: false)); + else + problems.Add($"[BuilderRequired] '{p.Identifier.Text}' has type '{type}': only string and the built-in value types are supported"); + if (p.Identifier.Text == stateProp) + problems.Add($"[BuilderRequired] '{p.Identifier.Text}' is the state: Build() sets it"); + } + + var transitions = new List(); + foreach (var m in entity.Members.OfType()) + foreach (var a in Attrs(m).Where(a => Named(a, "Transition"))) + { + var where = $"[Transition] on '{m.Identifier.Text}'"; + var argsList = a.ArgumentList?.Arguments ?? default; + if (argsList.Count != 3 || argsList.Any(x => x.NameEquals is not null || x.NameColon is not null)) + { + problems.Add($"{where}: expected (\"Name\", {stateEnum}.From, {stateEnum}.To)"); + continue; + } + if (argsList[0].Expression is not LiteralExpressionSyntax lit || !lit.IsKind(SyntaxKind.StringLiteralExpression) + || !SyntaxFacts.IsValidIdentifier(lit.Token.ValueText)) + { + problems.Add($"{where}: the name must be a string literal that is a C# identifier"); + continue; + } + string? State(ExpressionSyntax e) + { + if (e is MemberAccessExpressionSyntax { Expression: IdentifierNameSyntax owner } ma + && owner.Identifier.Text == stateEnum && states.Contains(ma.Name.Identifier.Text)) + return ma.Name.Identifier.Text; + problems.Add($"{where}: '{e}' is not a member of the state enum '{stateEnum}'"); + return null; + } + var from = State(argsList[1].Expression); + var to = State(argsList[2].Expression); + if (m.Modifiers.Any(SyntaxKind.PublicKeyword)) + problems.Add($"{where}: the hook must not be public: a public method writing protocol data is a transition nobody declared"); + if (m.Modifiers.Any(SyntaxKind.StaticKeyword) || m.TypeParameterList is not null + || m.ReturnType.ToString() != "void" || m.Body is null && m.ExpressionBody is null) + problems.Add($"{where}: the hook must be a non-generic instance method returning void, with a body"); + var ps = new List<(string, string)>(); + foreach (var p in m.ParameterList.Parameters) + { + if (p.Modifiers.Count > 0 || p.Default is not null || p.Type is null) + problems.Add($"{where}: parameter '{p.Identifier.Text}' must be a plain by-value parameter with no default"); + ps.Add((p.Type?.ToString() ?? "?", p.Identifier.Text)); + } + if (from is not null && to is not null) + transitions.Add(new Transition(lit.Token.ValueText, from, to, m.Identifier.Text, ps)); + } + if (transitions.Count == 0) + problems.Add("no [Transition] is declared"); + foreach (var dup in transitions.GroupBy(t => t.Name).Where(g => g.Count() > 1)) + problems.Add($"the transition name '{dup.Key}' is declared {dup.Count()} times"); + foreach (var t in transitions) + if (t.Name == "Id") + problems.Add("a transition may not be named 'Id': the tokens expose the entity's Id"); + + return new Model(source, ns, entity.Identifier.Text, id?.Type.ToString() ?? "int", stateProp, stateEnum, + states, fields, transitions); + } + + static bool PubliclyWritable(PropertyDeclarationSyntax p) + { + if (!p.Modifiers.Any(SyntaxKind.PublicKeyword)) + return false; + var setter = p.AccessorList?.Accessors.FirstOrDefault(a => a.IsKind(SyntaxKind.SetAccessorDeclaration)); + return setter is not null && setter.Modifiers.Count == 0; + } + } + + static class Emit + { + public static string Render(Model m) + { + var o = new StringBuilder(); + void L(string line = "") => o.Append(line).Append('\n'); + string Token(string state) => state + m.Entity; + var outgoing = m.States.Where(s => m.Transitions.Any(t => t.From == s)).ToList(); + var e = m.Entity; + var lower = char.ToLowerInvariant(e[0]) + e.Substring(1); + var invalid = $"Invalid{e}StateException"; + var corrupt = $"Corrupt{e}StateException"; + var initial = m.States[0]; + + L($"// Generated by Own.TypedBuilder from {m.Source}. Do not edit: change {m.Source} and regenerate."); + L("//"); + L("// The state protocol of " + e + ": one [ProtocolToken] per state, one transition per"); + L("// [Transition], one [ProtocolRegion] per state with a way out, the checked refinement,"); + L("// the strict storage of the state, and the staged builder. Own.NET analyses this file as"); + L("// source: it is the protocol's trusted definition surface."); + L(); + L("using System;"); + L(); + if (m.Namespace is not null) + { + L($"namespace {m.Namespace};"); + L(); + } + + // ---- the entity's generated half -------------------------------------------------- + L($"partial class {e}"); + L("{"); + foreach (var t in m.Transitions) + { + var ps = string.Join(", ", t.Parameters.Select(p => $"{p.Type} {p.Name}")); + var args = string.Join(", ", t.Parameters.Select(p => p.Name)); + L($" // {t.From} -> {t.Name} -> {t.To}"); + L($" internal void Apply{t.Name}({ps})"); + L(" {"); + L($" {t.Hook}({args});"); + L($" {m.StateProperty} = {m.StateEnum}.{t.To};"); + L(" }"); + L(); + } + // the first step is the innermost one (see EmitBuilder) + var first = string.Join(".", m.Fields.Select(f => f.Name + "Step").Reverse().Prepend($"{initial}Builder")); + L($" /// Starts a new {e} in its initial state, {initial}. Build() exists only once every"); + L(" /// required field was given."); + L($" public static {first} Create() => new();"); + L(); + EmitBuilder(m, L, initial); + L("}"); + L(); + + // ---- refusals --------------------------------------------------------------------- + L($"/// The runtime half of the refinement: the state came from data, so it is checked once,"); + L("/// at the region entry."); + L($"public sealed class {invalid}({m.IdType} id, {m.StateEnum} actual, {m.StateEnum} required)"); + L($" : InvalidOperationException($\"{lower} {{id}} is {{actual}}, not {{required}}\")"); + L("{"); + L($" public {m.IdType} Id {{ get; }} = id;"); + L(); + L($" public {m.StateEnum} Actual {{ get; }} = actual;"); + L(); + L($" public {m.StateEnum} Required {{ get; }} = required;"); + L("}"); + L(); + L($"/// A persisted state that is not exactly one of {m.StateEnum}'s names. Never mapped to a"); + L("/// state: no default, no case folding, no number."); + L($"public sealed class {corrupt}(string raw)"); + L($" : InvalidOperationException($\"the persisted {lower} state '{{raw}}' is not {Article(m.StateEnum)} {m.StateEnum}\")"); + L("{"); + L(" public string Raw { get; } = raw;"); + L("}"); + L(); + + // ---- strict storage --------------------------------------------------------------- + L($"/// The one mapping between {m.StateEnum} and its stored text: the exact member name."); + L($"public static class {m.StateEnum}Storage"); + L("{"); + L($" public static string ToStore({m.StateEnum} state) => state switch"); + L(" {"); + foreach (var s in m.States) + L($" {m.StateEnum}.{s} => \"{s}\","); + L($" _ => throw new {corrupt}(state.ToString()),"); + L(" };"); + L(); + L($" public static {m.StateEnum} FromStore(string raw) =>"); + L($" TryFromStore(raw, out var state) ? state : throw new {corrupt}(raw);"); + L(); + L($" public static bool TryFromStore(string? raw, out {m.StateEnum} state)"); + L(" {"); + L(" switch (raw)"); + L(" {"); + foreach (var s in m.States) + { + L($" case \"{s}\":"); + L($" state = {m.StateEnum}.{s};"); + L(" return true;"); + } + L(" default:"); + L(" state = default;"); + L(" return false;"); + L(" }"); + L(" }"); + L("}"); + L(); + + // ---- tokens ----------------------------------------------------------------------- + foreach (var s in m.States) + { + var exits = m.Transitions.Where(t => t.From == s).ToList(); + L(exits.Count == 0 + ? $"/// {e} in state {s}. Terminal: no transition leaves it." + : $"/// {e} in state {s}. A transition spends this token and hands back the next one."); + L("[ProtocolToken]"); + L($"public readonly ref struct {Token(s)}"); + L("{"); + L($" private readonly {e} _{lower};"); + L(); + L($" internal {Token(s)}({e} {lower}) => _{lower} = {lower};"); + L(); + L($" public {m.IdType} Id => _{lower}.Id;"); + foreach (var t in exits) + { + var ps = string.Join(", ", t.Parameters.Select(p => $"{p.Type} {p.Name}")); + var args = string.Join(", ", t.Parameters.Select(p => p.Name)); + L(); + L($" public {Token(t.To)} {t.Name}({ps})"); + L(" {"); + L($" _{lower}.Apply{t.Name}({args});"); + L($" return new {Token(t.To)}(_{lower});"); + L(" }"); + } + L("}"); + L(); + } + + // ---- regions ---------------------------------------------------------------------- + foreach (var s in outgoing) + L($"public delegate void {s}Region({Token(s)} {char.ToLowerInvariant(s[0]) + s.Substring(1)});"); + L(); + L($"/// The checked refinement: {Article(e)} {e} whose state is only known at run time becomes a token"); + L("/// inside the callback, or the call throws and no token exists. A state no transition"); + L("/// leaves has no region: its token could never be spent."); + L($"public static class {e}Protocol"); + L("{"); + for (var i = 0; i < outgoing.Count; i++) + { + var s = outgoing[i]; + if (i > 0) + L(); + L(" [ProtocolRegion]"); + L($" public static void With{s}({e} {lower}, {s}Region body)"); + L(" {"); + L($" ArgumentNullException.ThrowIfNull({lower});"); + L(" ArgumentNullException.ThrowIfNull(body);"); + L($" if ({lower}.{m.StateProperty} != {m.StateEnum}.{s})"); + L($" throw new {invalid}({lower}.Id, {lower}.{m.StateProperty}, {m.StateEnum}.{s});"); + L($" body(new {Token(s)}({lower}));"); + L(" }"); + } + L("}"); + return o.ToString(); + } + + static string Article(string word) => "AEIOUaeiou".Contains(word[0]) ? "an" : "a"; + + // The staged builder, nested inside the entity so it can reach its private constructor + // and setters. The steps nest INWARD from the finished builder: each step constructs the + // type that contains it through a private constructor, so the only way to a Build() is + // through every step, in order. + static void EmitBuilder(Model m, Action L, string initial) + { + var e = m.Entity; + var depth = 1; + string Pad() => new(' ', depth * 4); + string Lower(string s) => char.ToLowerInvariant(s[0]) + s.Substring(1); + + L($"{Pad()}public sealed class {initial}Builder"); + L($"{Pad()}{{"); + depth++; + foreach (var f in m.Fields) + L($"{Pad()}private readonly {f.Type} _{Lower(f.Name)};"); + if (m.Fields.Count > 0) + L(""); + var all = string.Join(", ", m.Fields.Select(f => $"{f.Type} {Lower(f.Name)}")); + L($"{Pad()}{(m.Fields.Count == 0 ? "internal" : "private")} {initial}Builder({all})"); + L($"{Pad()}{{"); + foreach (var f in m.Fields) + L($"{Pad()} _{Lower(f.Name)} = {Lower(f.Name)};"); + L($"{Pad()}}}"); + L(""); + var init = string.Join(", ", m.Fields.Select(f => $"{f.Name} = _{Lower(f.Name)}") + .Append($"{m.StateProperty} = {m.StateEnum}.{initial}")); + L($"{Pad()}public {e} Build() => new() {{ {init} }};"); + + // step k holds fields [0, k) and takes field k; the steps nest so that step k sits + // inside step k+1 (the last step inside the builder). + var opened = 0; + for (var k = m.Fields.Count - 1; k >= 0; k--) + { + var f = m.Fields[k]; + var held = m.Fields.Take(k).ToList(); + var next = k == m.Fields.Count - 1 ? $"{initial}Builder" : $"{m.Fields[k + 1].Name}Step"; + L(""); + L($"{Pad()}public sealed class {f.Name}Step"); + L($"{Pad()}{{"); + depth++; + foreach (var h in held) + L($"{Pad()}private readonly {h.Type} _{Lower(h.Name)};"); + if (held.Count > 0) + L(""); + var ctor = string.Join(", ", held.Select(h => $"{h.Type} {Lower(h.Name)}")); + // the first step is where Create() starts: it holds nothing, so making one by + // hand gains nothing + L($"{Pad()}{(k == 0 ? "internal" : "private")} {f.Name}Step({ctor})"); + L($"{Pad()}{{"); + foreach (var h in held) + L($"{Pad()} _{Lower(h.Name)} = {Lower(h.Name)};"); + L($"{Pad()}}}"); + L(""); + var pass = string.Join(", ", held.Select(h => $"_{Lower(h.Name)}").Append(Lower(f.Name))); + L($"{Pad()}public {next} {f.Name}({f.Type} {Lower(f.Name)})"); + L($"{Pad()}{{"); + if (f.NullCheck) + L($"{Pad()} ArgumentNullException.ThrowIfNull({Lower(f.Name)});"); + L($"{Pad()} return new {next}({pass});"); + L($"{Pad()}}}"); + opened++; + } + for (var i = 0; i < opened; i++) + { + depth--; + L($"{Pad()}}}"); + } + depth--; + L($"{Pad()}}}"); + } + } +} diff --git a/frontend/roslyn/OwnSharp.Cli/BuildCheckCommand.cs b/frontend/roslyn/OwnSharp.Cli/BuildCheckCommand.cs new file mode 100644 index 00000000..34e168f2 --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/BuildCheckCommand.cs @@ -0,0 +1,397 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace OwnSharp.Cli; + +/// +/// `owen build-check --request <file>` — the generic Owen build host (OX-01, +/// spec/OwenExtension.md). Called by the `Owen.Build` package's MSBuild targets after a project +/// has been built; not meant to be typed. +/// +/// It is owen check with three additions, none of them analysis: +/// +/// it validates the extension descriptors the project's packages declare against +/// what this host supports, and fails loud on anything it does not understand; +/// it hands the C# that those extensions' generators wrote to the extractor explicitly, +/// beside the project (the extractor's own expansion skips generated files, and an +/// extension's protocol lives in generated code); +/// it speaks MSBuild: every failure is ONE canonical line (origin: error OWENBnnn: +/// text, with file(line) wherever there is one), so the build fails on it and the +/// Error List can navigate to it. +/// +/// +/// The findings themselves are the Rust core's, rendered by the core +/// (--format msbuild) and passed through byte for byte. This host renders nothing of +/// its own and knows no extension by name. +/// +/// Exit: 0 analysed (findings shown as warnings, or none); 1 analysed, findings shown as +/// errors; 2 a host / contract / refusal error, already printed as an OWENB line. +/// +internal static class BuildCheckCommand +{ + /// The descriptor schema this host reads (`owen_extension`). + public const int DescriptorSchema = 1; + + /// The OwnIR version the packed core speaks (spec/OwnIR.md §2). + public const int OwnIr = 2; + + /// What an extension may require of this host. A capability is a promise about + /// the analysis the packed extractor + core perform; it is added here only when they + /// do. + public static readonly string[] Capabilities = ["heap-effects", "ownership", "proven-call", "state-protocol"]; + + private sealed record Descriptor(string Path, string Id, string Version, IReadOnlyList Capabilities, + IReadOnlyList Generators); + + public static async Task RunAsync(string[] args) + { + if (args.Length != 2 || args[0] != "--request") + { + Console.Error.WriteLine("usage: owen build-check --request (written by the Owen.Build targets)"); + return 2; + } + + Dictionary> request; + try + { + request = ReadRequest(args[1]); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.WriteLine($"owen: error OWENB012: cannot read the build request '{args[1]}': {ex.Message}"); + return 2; + } + + var project = One(request, "project"); + var severity = One(request, "severity") ?? "warning"; + var manifest = One(request, "manifest"); + var generatedRoot = One(request, "generated-root"); + var emitFacts = One(request, "emit-facts"); + if (project is null || manifest is null) + { + Console.WriteLine("owen: error OWENB012: the build request names no project or no manifest path"); + return 2; + } + if (severity is not ("warning" or "error")) + { + Console.WriteLine($"{project}: error OWENB002: OwenSeverity must be 'warning' or 'error', not '{severity}'"); + return 2; + } + + // ---- 1. the extensions -------------------------------------------------------------- + var paths = request.TryGetValue("descriptor", out var given) ? given : []; + if (paths.Count == 0) + { + Console.WriteLine($"{project}: warning OWENB001: Owen.Build is referenced but no Owen extension is active; nothing was analysed"); + WriteManifest(manifest, []); + return 0; + } + var descriptors = new List(); + var failed = false; + foreach (var path in paths.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)) + { + var d = ReadDescriptor(path, out var errors); + foreach (var e in errors) + Console.WriteLine(e); + failed |= errors.Count > 0; + if (d is not null) + descriptors.Add(d); + } + foreach (var dup in descriptors.GroupBy(d => d.Id, StringComparer.Ordinal).Where(g => g.Count() > 1)) + { + Console.WriteLine($"{dup.Last().Path}: error OWENB002: extension '{dup.Key}' is declared {dup.Count()} times"); + failed = true; + } + if (failed) + return 2; + descriptors.Sort((a, b) => StringComparer.Ordinal.Compare(a.Id, b.Id)); + WriteManifest(manifest, descriptors); + Console.WriteLine("Owen: active extensions: " + string.Join(", ", descriptors.Select(d => $"{d.Id} {d.Version}"))); + + // ---- 2. the inputs ------------------------------------------------------------------ + var inputs = new List { project }; + if (generatedRoot is not null) + { + foreach (var generator in descriptors.SelectMany(d => d.Generators).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)) + { + var dir = Path.Combine(generatedRoot, generator); + if (Directory.Exists(dir)) + inputs.AddRange(Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories) + .Order(StringComparer.Ordinal)); + } + } + + // ---- 3. the engine ------------------------------------------------------------------ + RustCore core; + try + { + core = RustCoreLocator.Resolve(); + } + catch (RustCoreNotResolvedException ex) + { + Console.WriteLine($"{project}: error OWENB005: {OneLine(ex.Message)}"); + return 2; + } + + var factsPath = Path.GetTempFileName(); + try + { + var (extractRc, extractOutput) = await CheckCommand + .RunExtractorAsync(inputs, factsPath, legacy: false, stats: false, bodyThrowEdges: false) + .ConfigureAwait(false); + if (extractRc == 2) + { + foreach (var line in Canonical(extractOutput, project, "OWENB010", ExtractorRefusal)) + Console.WriteLine(Absolute(line, project)); + return 2; + } + if (extractRc != 0) + { + Console.WriteLine($"{project}: error OWENB012: the extractor exited {extractRc}: {OneLine(extractOutput)}"); + return 2; + } + if (emitFacts is not null) + File.Copy(factsPath, emitFacts, overwrite: true); + + EngineOutcome outcome; + try + { + outcome = await EngineRunner.RunRustAsync(core, factsPath, "msbuild", severity, capture: true) + .ConfigureAwait(false); + } + catch (EngineRunner.RustCoreNotStartedException ex) + { + Console.WriteLine($"{project}: error OWENB005: {OneLine(ex.Message)}"); + return 2; + } + var stdout = Encoding.UTF8.GetString(outcome.Stdout); + var stderr = Encoding.UTF8.GetString(outcome.Stderr); + switch (outcome.Rc) + { + case 0 or 1: + // the core's own canonical lines, unchanged but for the origin path: the + // core names files relative to the project directory (the extractor ran + // there), and the Error List must not have to guess the base + Console.Write(Absolute(stdout, project)); + Console.Write(Absolute(stderr, project)); + return outcome.Rc == 1 && severity == "error" ? 1 : 0; + case 2: + foreach (var line in Canonical(stderr + stdout, project, "OWENB011", CoreRefusal)) + Console.WriteLine(Absolute(line, project)); + return 2; + default: + Console.WriteLine($"{project}: error OWENB012: the Rust core exited {outcome.Rc}, which is not a verdict: {OneLine(stderr)}"); + return 2; + } + } + finally + { + try { File.Delete(factsPath); } catch (IOException) { /* best-effort */ } + } + } + + // ---- descriptors -------------------------------------------------------------------------- + + private static Descriptor? ReadDescriptor(string path, out List errors) + { + var found = new List(); + errors = found; + void Bad(string code, string text) => found.Add($"{path}: error {code}: {text}"); + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(File.ReadAllBytes(path)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + Bad("OWENB002", $"the extension descriptor cannot be read: {OneLine(ex.Message)}"); + return null; + } + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + Bad("OWENB002", "the extension descriptor is not a JSON object"); + return null; + } + if (!root.TryGetProperty("owen_extension", out var schema) || schema.ValueKind != JsonValueKind.Number + || !schema.TryGetInt32(out var schemaVersion)) + { + Bad("OWENB002", "the extension descriptor has no integer 'owen_extension' schema version"); + return null; + } + if (schemaVersion != DescriptorSchema) + { + Bad("OWENB002", $"descriptor schema owen_extension={schemaVersion} is not one this host reads (it reads {DescriptorSchema}); update Owen.Build"); + return null; + } + var id = Text(root, "id"); + var version = Text(root, "version"); + if (id is null || version is null) + { + Bad("OWENB002", "the extension descriptor needs non-empty string 'id' and 'version'"); + return null; + } + if (!root.TryGetProperty("requires", out var requires) || requires.ValueKind != JsonValueKind.Object) + { + Bad("OWENB002", $"extension '{id}' has no 'requires' object"); + return null; + } + var host = Text(requires, "host"); + if (host is null || !Version.TryParse(host, out var needHost)) + { + Bad("OWENB002", $"extension '{id}': 'requires.host' must be a version such as \"0.1.0\""); + return null; + } + if (!requires.TryGetProperty("ownir", out var ownir) || ownir.ValueKind != JsonValueKind.Number + || !ownir.TryGetInt32(out var needOwnIr)) + { + Bad("OWENB002", $"extension '{id}': 'requires.ownir' must be an integer"); + return null; + } + var capabilities = Strings(requires, "capabilities"); + if (capabilities is null) + { + Bad("OWENB002", $"extension '{id}': 'requires.capabilities' must be an array of strings"); + return null; + } + IReadOnlyList generators = []; + if (root.TryGetProperty("frontend", out var frontend)) + { + if (frontend.ValueKind != JsonValueKind.Object + || (frontend.TryGetProperty("generators", out _) && Strings(frontend, "generators") is null)) + { + Bad("OWENB002", $"extension '{id}': 'frontend.generators' must be an array of strings"); + return null; + } + generators = Strings(frontend, "generators") ?? []; + } + + var hostVersion = Version.Parse(ToolVersion.Current); + if (needHost > hostVersion) + Bad("OWENB003", $"extension '{id}' {version} requires Owen host {needHost}, this host is {hostVersion}; update Owen.Build"); + if (needOwnIr != OwnIr) + Bad("OWENB003", $"extension '{id}' {version} requires OwnIR {needOwnIr}, this host's core speaks OwnIR {OwnIr}"); + foreach (var unknown in capabilities.Where(c => !Capabilities.Contains(c, StringComparer.Ordinal))) + Bad("OWENB004", $"extension '{id}' {version} requires capability '{unknown}', which this host does not provide (it provides: {string.Join(", ", Capabilities)})"); + return found.Count > 0 ? null : new Descriptor(path, id, version, capabilities, generators); + } + } + + private static string? Text(JsonElement e, string name) => + e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s + ? s + : null; + + private static List? Strings(JsonElement e, string name) + { + if (!e.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Array) + return null; + var list = new List(); + foreach (var item in v.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String || item.GetString() is not { Length: > 0 } s) + return null; + list.Add(s); + } + return list; + } + + /// obj/owen/extensions.json: what is active in this project, sorted, byte-stable. + private static void WriteManifest(string path, IReadOnlyList descriptors) + { + var o = new StringBuilder(); + o.Append("{\n"); + o.Append($" \"owen_host\": \"{ToolVersion.Current}\",\n"); + o.Append($" \"ownir\": {OwnIr},\n"); + o.Append(" \"extensions\": ["); + for (var i = 0; i < descriptors.Count; i++) + { + var d = descriptors[i]; + o.Append(i == 0 ? "\n" : ",\n"); + o.Append(" {\n"); + o.Append($" \"id\": {JsonSerializer.Serialize(d.Id)},\n"); + o.Append($" \"version\": {JsonSerializer.Serialize(d.Version)},\n"); + o.Append($" \"capabilities\": [{string.Join(", ", d.Capabilities.Order(StringComparer.Ordinal).Select(c => JsonSerializer.Serialize(c)))}]\n"); + o.Append(" }"); + } + o.Append(descriptors.Count == 0 ? "]\n" : "\n ]\n"); + o.Append("}\n"); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + File.WriteAllText(path, o.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + // ---- refusals, as canonical MSBuild lines ---------------------------------------------------- + + // extractor: protocol lowering refused: OrderEndpoints.cs:17: + private static readonly Regex ExtractorRefusal = new(@"refused:\s+(?[^\r\n]+?):(?\d+):\s+(?.+)$"); + + // facts.json: error: + private static readonly Regex CoreRefusal = new(@"^.*?:\s+error:\s+(?.*\((?[^()\r\n]+):(?\d+)\).*)$"); + + /// One canonical error per refusal line that names a location; one project-level + /// error carrying the whole output when none does. Never zero lines. + private static List Canonical(string output, string project, string code, Regex located) + { + var lines = new List(); + foreach (var raw in output.Split('\n')) + { + var line = raw.TrimEnd('\r'); + var m = located.Match(line); + if (m.Success) + lines.Add($"{m.Groups["file"].Value}({m.Groups["line"].Value}): error {code}: {m.Groups["text"].Value}"); + } + if (lines.Count == 0) + lines.Add($"{project}: error {code}: {OneLine(output)}"); + return lines; + } + + // `File.cs(18): warning OWN002: …` / `File.cs(18,3): error …` + private static readonly Regex CanonicalOrigin = new(@"^(?[^\r\n(]+?)\((?\d+(?:,\d+)*)\): (?(?:warning|error) .*)$"); + + /// Make the origin of every canonical line absolute against the project + /// directory; every other byte, the message text included, is left as it is. + private static string Absolute(string text, string project) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(project))!; + // split and re-join on '\n': a trailing newline survives as the empty last piece + return string.Join('\n', text.Split('\n').Select(piece => + { + var line = piece.TrimEnd('\r'); + var m = CanonicalOrigin.Match(line); + return m.Success && !Path.IsPathRooted(m.Groups["file"].Value) + ? $"{Path.GetFullPath(Path.Combine(dir, m.Groups["file"].Value))}({m.Groups["pos"].Value}): {m.Groups["rest"].Value}" + : piece; + })); + } + + private static string OneLine(string text) => + string.Join(" ", text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim())); + + // ---- the request file --------------------------------------------------------------------- + + private static Dictionary> ReadRequest(string path) + { + var map = new Dictionary>(StringComparer.Ordinal); + foreach (var raw in File.ReadAllLines(path)) + { + var tab = raw.IndexOf('\t'); + if (tab <= 0) + continue; + var key = raw[..tab]; + var value = raw[(tab + 1)..].Trim(); + if (value.Length == 0) + continue; + if (!map.TryGetValue(key, out var list)) + map[key] = list = []; + list.Add(value); + } + return map; + } + + private static string? One(Dictionary> map, string key) => + map.TryGetValue(key, out var list) && list.Count > 0 ? list[^1] : null; +} diff --git a/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs b/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs index e2a7caf1..2c3aac14 100644 --- a/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs +++ b/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs @@ -378,7 +378,7 @@ private static bool HasSupportedInput(IReadOnlyList paths, out string re /// is CAPTURED and returned; the caller decides what reaches OUR stderr /// (everything for the contract codes, report-only for a crash — A1), /// keeping stdout clean for stage 2 like own-check.sh's `1>&2`. - private static async Task<(int Rc, string Output)> RunExtractorAsync( + internal static async Task<(int Rc, string Output)> RunExtractorAsync( IReadOnlyList paths, string factsPath, bool legacy, bool stats, bool bodyThrowEdges) { // "ownsharp-extract.dll" is OwnSharp.Extractor's own real AssemblyName/output @@ -440,6 +440,13 @@ private static bool HasSupportedInput(IReadOnlyList paths, out string re /// (the ToolCommandName-based shim), not the dotnet muxer. private static string ResolveDotnetMuxer() { + // Under MSBuild (the Owen.Build host) the muxer running the build is named exactly: + // use it, so a build never depends on what `dotnet` a PATH search would find. + var host = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); + if (!string.IsNullOrEmpty(host) && File.Exists(host)) + { + return host; + } var root = Environment.GetEnvironmentVariable("DOTNET_ROOT"); if (!string.IsNullOrEmpty(root)) { diff --git a/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj index 81cfce2b..1761ff99 100644 --- a/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj +++ b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj @@ -119,20 +119,18 @@ is a file or a folder by whether it looks like it has an extension, so `own-cli.exe` behaved differently from `own-cli`, and the extensionless Unix binary is the one that had to be right. --> + + - - - - - + + + netstandard2.0 + false + true + Owen.TestExtension + 0.1.0 + Test fixture: a synthetic Owen extension (descriptor only). Not published. + PhysShell + $(NoWarn);NU5128 + false + + + + + + + diff --git a/tests/owen-extensions/Owen.TestExtension/buildTransitive/Owen.TestExtension.props b/tests/owen-extensions/Owen.TestExtension/buildTransitive/Owen.TestExtension.props new file mode 100644 index 00000000..56a6fb18 --- /dev/null +++ b/tests/owen-extensions/Owen.TestExtension/buildTransitive/Owen.TestExtension.props @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/owen-extensions/Owen.TestExtension/buildTransitive/owen-extension.json b/tests/owen-extensions/Owen.TestExtension/buildTransitive/owen-extension.json new file mode 100644 index 00000000..e3fde18e --- /dev/null +++ b/tests/owen-extensions/Owen.TestExtension/buildTransitive/owen-extension.json @@ -0,0 +1,10 @@ +{ + "owen_extension": 1, + "id": "Owen.TestExtension", + "version": "0.1.0", + "requires": { + "host": "0.1.0", + "ownir": 2, + "capabilities": ["ownership"] + } +} From 89bdb8664bd5253869fed5d262a54a3162991cfa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:47:54 +0000 Subject: [PATCH 3/5] fix(owen-ext): gate's outside-checkout check across Windows drives On windows-latest the checkout is on D: and the temp directory on C:, and os.path.commonpath raises on paths from two drives. Two drives means the consumer is certainly outside the checkout. Gate mechanics only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFvhLx1fM9Gerg4dKsZcL --- scripts/owen_extension_gate.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/owen_extension_gate.py b/scripts/owen_extension_gate.py index 592d02cc..639570af 100644 --- a/scripts/owen_extension_gate.py +++ b/scripts/owen_extension_gate.py @@ -219,11 +219,11 @@ def isolation(c: Consumer) -> None: not leaked, f"on the consumer PATH: {leaked or 'no python/cargo/rustc/owen'}", ) - check( - "E-outside-checkout", - os.path.commonpath([os.path.realpath(c.dir), ROOT]) != ROOT, - "the consumer directory is not inside the Own.NET checkout", - ) + try: + inside = os.path.commonpath([os.path.realpath(c.dir), ROOT]) == ROOT + except ValueError: # Windows: different drives, so certainly not inside + inside = False + check("E-outside-checkout", not inside, "the consumer is outside the Own.NET checkout") def scaffold(c: Consumer) -> None: From 48c71e60f7bf04f14c2789d5550c17d15a2eb8c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:47:54 +0000 Subject: [PATCH 4/5] docs(owen-ext): diagnostics model, E1/E2 IDE feasibility (E2 spike), alpha readiness - owen-diagnostics-model.md (P8): the model already exists (own_bridge::Finding -> render_finding/build_sarif); no new layer. - owen-extension-ide-feasibility.md (P9/P10): E1 generic VSIX host NOT EXECUTED here (no Windows/VS), specified with its two blockers; E2 native Roslyn analyzer REJECT, measured on Linux (shadow copy defeats native resolution; only an explicit path via reflection-reached NativeLibrary works, .NET hosts only; the extractor cannot run inside an analyzer). - experiments/owen-e2-native-analyzer: the E2 spike (outside shipping and CI). - alpha-readiness.md: the PackageReference install path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFvhLx1fM9Gerg4dKsZcL --- docs/notes/alpha-readiness.md | 33 ++++ docs/notes/owen-diagnostics-model.md | 56 +++++++ docs/notes/owen-extension-ide-feasibility.md | 83 ++++++++++ .../owen-e2-native-analyzer/.gitignore | 2 + experiments/owen-e2-native-analyzer/README.md | 32 ++++ .../analyzer/E2.Analyzer.csproj | 13 ++ .../analyzer/NativeOwenAnalyzer.cs | 149 ++++++++++++++++++ .../owen-e2-native-analyzer/native/Cargo.toml | 18 +++ .../owen-e2-native-analyzer/native/src/lib.rs | 29 ++++ 9 files changed, 415 insertions(+) create mode 100644 docs/notes/owen-diagnostics-model.md create mode 100644 docs/notes/owen-extension-ide-feasibility.md create mode 100644 experiments/owen-e2-native-analyzer/.gitignore create mode 100644 experiments/owen-e2-native-analyzer/README.md create mode 100644 experiments/owen-e2-native-analyzer/analyzer/E2.Analyzer.csproj create mode 100644 experiments/owen-e2-native-analyzer/analyzer/NativeOwenAnalyzer.cs create mode 100644 experiments/owen-e2-native-analyzer/native/Cargo.toml create mode 100644 experiments/owen-e2-native-analyzer/native/src/lib.rs diff --git a/docs/notes/alpha-readiness.md b/docs/notes/alpha-readiness.md index ba6f290a..e4bed9f7 100644 --- a/docs/notes/alpha-readiness.md +++ b/docs/notes/alpha-readiness.md @@ -68,6 +68,39 @@ None of those is research; all are the difference between "interesting PoC" and standing between here and the day 1–30 milestone being *literally* copy-paste for a stranger. +## Update 2026-10-03: the extension path (OX-01) + +**A second install path now exists beside A.** No `dotnet tool` and no command: a +`PackageReference`. + +``` +dotnet add package Owen.TypedBuilder +dotnet build +``` + +**What it does.** +- The package depends on **`Owen.Build`**, the generic build host. `Owen.Build` carries the same program as `Owen.Cli` (`owen`), its bundled extractor, and the Rust core for `linux-x64` and `win-x64`. +- On every build it runs Owen once for every active Owen extension. +- Findings are ordinary MSBuild diagnostics, so `dotnet build`, CI logs and Visual Studio's Error List all show them. They are `warning` by default and `OwenSeverity=error` to fail the build. + +The machine needs no Python, no Rust toolchain, no global tool and no checkout. The isolated +consumer gate proves it on Linux and Windows CI (`scripts/owen_extension_gate.py`). + +**Extension #1 is the Typed Builder:** +- typed states and a typed builder generated from one annotated EF entity; +- illegal transitions are compiler errors; +- stale or copied states are `OWN` findings on build. + +The contract a second extension uses is in [`spec/OwenExtension.md`](../../spec/OwenExtension.md). See +[`owen-extension-alpha-report.md`](owen-extension-alpha-report.md) for the evidence, and +[`owen-extension-ide-feasibility.md`](owen-extension-ide-feasibility.md) for live IDE diagnostics: +- E1 (one generic VSIX host) is the direction, not built here; +- E2 (a native Roslyn analyzer) was rejected. + +**Still open.** Exactly the items that already gated A: nothing is published to nuget.org +yet, and there is no license. `Owen.Build` and `Owen.TypedBuilder` are 0.1.0 candidates packed +from source. + ## The 20% rule (other stacks) Other stacks are **proof of portability, not a second product.** Sanctioned now: diff --git a/docs/notes/owen-diagnostics-model.md b/docs/notes/owen-diagnostics-model.md new file mode 100644 index 00000000..1522a6db --- /dev/null +++ b/docs/notes/owen-diagnostics-model.md @@ -0,0 +1,56 @@ +# Owen diagnostics: the model already exists (OX-01, P8) + +**Question.** Is the renderer so tied to CLI strings that the extension substrate needs a new +internal diagnostic model before `msbuild`, `github`, `sarif`, a future Visual Studio host or a +future LSP can be served? + +**Answer: no.** The model exists, it is the authoritative core's, and every surface is +already one function of it. OX-01 adds **no** diagnostic layer. It consumes the existing one. + +## The model + +`own_bridge::verdict::Finding` (Rust, authoritative since the P-022 Stage 3 cutover; the Python +reference mirrors it field for field): + +| field | the brief's concept | +|---|---| +| `code` | code (`OWN001` …) | +| `severity` (+ `advisory`) | severity. The host's `--severity` changes only how a finding is **shown**, never the verdict | +| `message` | message | +| `file`, `line`, `column` (optional) | file, start line, start column | +| `related: Vec`, `flow: Vec` | related locations and the witness path | +| `kind` (`[resource: …]`), `component`, `event`, `handler` | the resource the finding is about | +| `ignore_reason` | suppression (`// own:ignore` …) | + +**Renderers,** each a pure function of a `Finding`: + +| function | output | +|---|---| +| `own_bridge::render_finding(f, "human" \| "github" \| "msbuild", severity)` | one line per surface | +| `own_bridge::build_sarif` | a SARIF 2.1.0 log | + +`own-cli ownir --format {human,github,msbuild,sarif}` exposes all four. `BR-V9` pins the +bytes, and the CLI ledgers pin both engines. + +## How OX-01 uses it + +The build host (`owen build-check`) asks the core for `--format msbuild` and passes the lines +through. It changes exactly one thing: it makes the **origin path** absolute against the project +directory, so Visual Studio's Error List never has to guess a base. No message, code or +severity is re-rendered by the host. + +The host's own conditions are the `OWENB` codes (spec/OwenExtension.md §4): +- a descriptor rejected; +- no engine for the platform; +- a refusal. + +They are rendered by the host in the same canonical MSBuild shape. They are host +diagnostics, not analysis findings, and they never reuse an `OWN` code. + +## Gaps, recorded and not changed here + +- **No column on the `msbuild` line.** `render_msbuild` prints `file(line)` although `Finding` carries an optional `column`. + - **Effect:** the Error List navigates to the line, not the column. + - **Why not here:** changing the line is a change to the core's pinned bytes (BR-V9, the CLI ledgers), which is foundation work. +- **The line and the wording of state-protocol findings.** A stale state token is reported at the region entry, and in the generic resource wording ("IDisposable local … disposed"). This is issue **#393**, unchanged here. +- **For a future IDE host or LSP:** both consume the same `Finding`. `--format sarif` already carries everything they need: code, level, message, region, related locations, code flows. A future `Owen.VisualStudio` or language server should read SARIF, or a structured JSON of the same `Finding`, from the one core, and never re-derive a finding. diff --git a/docs/notes/owen-extension-ide-feasibility.md b/docs/notes/owen-extension-ide-feasibility.md new file mode 100644 index 00000000..97c904c3 --- /dev/null +++ b/docs/notes/owen-extension-ide-feasibility.md @@ -0,0 +1,83 @@ +# Owen in the IDE: E1 and E2 feasibility (OX-01, P9 and P10) + +This is a kill-first report, not a product. Nothing here ships. The shipped path in OX-01 is +the **build host**: +- `Owen.Build` runs Owen on every `dotnet build`; +- its findings are canonical MSBuild diagnostics; +- Visual Studio puts canonical MSBuild diagnostics in the Error List on build, with file and line navigation (spec/OwenExtension.md). + +The question here is the next step: **live** diagnostics while typing. + +## The architecture invariant (from the clang-tidy model) + +One authoritative external analyzer and thin adapters. Concretely: +- **An extension is not an IDE plugin.** There is no `Owen.TypedBuilder.VisualStudio`, no `Owen.Memory.VisualStudio`. +- **At most one `Owen.VisualStudio`,** serving every active extension from the manifest the build host already writes (`obj/owen/extensions.json`). +- **At most one Owen diagnostic/language server.** +- **Every adapter reads findings produced by the one Rust core.** None re-derives a verdict in C# or TypeScript (owen-diagnostics-model.md). + +clang-tidy's integration code is not copied; only the shape is borrowed. + +## E1: a generic VSIX host (`IErrorTag` + `ITableDataSource` + a long-lived Owen process) + +**Verdict: NOT EXECUTED in this environment.** The session runs on Linux with no Windows +and no Visual Studio, so a VSIX cannot be built, deployed to an experimental instance or +observed. Nothing below is measured. It is the specification of the spike, plus the two +blockers already visible from the code. + +**The acceptance the spike must meet** (as registered): +1. an unsaved C# buffer; +2. an OWN diagnostic; +3. a squiggle (`IErrorTag`); +4. an Error List entry (`ITableDataSource`); +5. navigation to it; +6. delete the offending line, and the diagnostic disappears. + +**Blockers found by reading the code; both must be solved before E1 can pass:** +1. **Unsaved buffers.** + - **The gap:** the extractor reads **files** (`OwnSharp.Extractor/Program.cs::Expand`), and an unsaved buffer is not a file. Live diagnostics need an overlay input, "this path, these contents", or an in-memory workspace. + - **Scope:** this is frontend work, not core semantics, and it is the precondition of any live host, E1 and E2 alike. +2. **Latency.** + - **The cost today:** one `owen check` spawns the extractor, compiles the project with Roslyn and runs the core: seconds per project. The build host pays that once per build, which is acceptable. A host that runs on every keystroke cannot. + - **What E1 implies:** a **long-lived** Owen process with an incremental extractor, which does not exist today. + - **What Snipper shows** (owen-extension-snipper-salvage.md): its long-lived process lifecycle is the part to **reject**. Stderr is lost, crashes are unwatched, and orphans are not prevented. A future host needs a lifecycle written for this, not that one. + +**Salvage that applies when E1 is attempted** (from the Snipper audit): +- **ADAPT** the mocked-VS integration tests; +- **COPY** the fake two-pipe JSON-RPC server pattern; +- **ADAPT** the `/rootsuffix` real-IDE smoke as a manual or nightly gate (not CI). + +**Recommendation.** E1 is the right *shape* for live diagnostics, but it is a separate slice +whose first task is the extractor overlay input (1) and a resident analysis process (2). It +must be run on Windows with Visual Studio against the six-step acceptance above. + +## E2: a NuGet-only Roslyn `DiagnosticAnalyzer` calling the native Rust core + +**Verdict: REJECT** (for the Alpha and as the live-diagnostics route). + +**What was run.** `experiments/owen-e2-native-analyzer/`, on Linux x64 with the .NET 8 SDK: +- a `cdylib` exporting `owen_e2_check(facts) -> msbuild lines`, the SAME `own_bridge::check_facts` + `render_finding` as the CLI; +- a `netstandard2.0` analyzer that P/Invokes it and maps the lines to `Location`s; +- a consumer project. + +| check | result | +|---|---| +| Linux, `dotnet build` (compiler server), variant 1: `DllImport` + `DllImportSearchPath.AssemblyDirectory` | **FAIL**: `DllNotFoundException` on every build. Roslyn shadow-copies analyzer assemblies and does not copy native files beside them. | +| variant 2: explicit path via `CompilerVisibleProperty` + `NativeLibrary.Load`, reached by reflection (absent from `netstandard2.0`) | **works**: `C8.cs(18,1): warning OWNE2: OWN002: …` | +| repeated builds through the compiler server | identical over 3 builds | +| after `dotnet build-server shutdown` | identical | +| the native file replaced under a live server | the next load fails; no stale fallback | +| Windows, Visual Studio x64 in-proc / ServiceHub, unload/reload | **NOT EXECUTED** (no Windows here) | +| no duplicate semantics in C# | holds for the **verdict**. **Fails for the facts**: an analyzer cannot run the extractor, a separate program that reads files. Extraction would have to move inside the analyzer, which is a second frontend host. | + +**Why REJECT.** +1. **Only a hand-built workaround works.** The default native-asset resolution, the thing a NuGet-only analyzer relies on, fails outright under the shadow copy. +2. **The workaround is .NET-hosted only.** `NativeLibrary` exists only on .NET (Core) hosts. A .NET Framework compiler host needs a third, Windows-only `LoadLibrary` path. +3. **Native libraries never unload.** On Windows they are locked by long-lived compiler and IDE processes, which fights package updates. +4. **The analyzer would still need the extractor in process.** That duplicates the frontend host the build path already has. + +**What would change the verdict:** +- the same extractor overlay/library refactor that E1 needs; +- a measured Windows + Visual Studio run of variant 2. + +Until then the build host is the shipped path, and E1 is the live-diagnostics direction. diff --git a/experiments/owen-e2-native-analyzer/.gitignore b/experiments/owen-e2-native-analyzer/.gitignore new file mode 100644 index 00000000..8381c53c --- /dev/null +++ b/experiments/owen-e2-native-analyzer/.gitignore @@ -0,0 +1,2 @@ +native/target/ +native/Cargo.lock diff --git a/experiments/owen-e2-native-analyzer/README.md b/experiments/owen-e2-native-analyzer/README.md new file mode 100644 index 00000000..c21c6528 --- /dev/null +++ b/experiments/owen-e2-native-analyzer/README.md @@ -0,0 +1,32 @@ +# E2 spike: a Roslyn analyzer calling the native Rust core (OX-01) + +**Experimental.** Outside every shipping path and every CI build. It exists to answer one +kill-first question from `docs/notes/owen-extension-alpha-preregistration.md`: can live OWN +diagnostics ship as a NuGet-only Roslyn analyzer that calls the Rust core in process? + +**Verdict: REJECT for the Alpha.** The reasons and the evidence are in +`docs/notes/owen-extension-ide-feasibility.md` (§ E2). + +| piece | what it is | +|---|---| +| `native/` | a `cdylib` exporting `owen_e2_check(facts) -> msbuild lines`. It is the SAME `own_bridge::check_facts` + `render_finding` the CLI runs, with no semantics of its own, built in its own Cargo workspace | +| `analyzer/` | a `netstandard2.0` `DiagnosticAnalyzer`. It reads OwnIR facts from an `AdditionalFiles` entry and maps the core's `file(line): warning CODE: text` lines onto `Location`s | + +Run on Linux x64, .NET 8 SDK: + +```sh +(cd native && cargo build --release) +(cd analyzer && dotnet build -c Release) +# a consumer referencing analyzer/bin/Release/netstandard2.0/E2.Analyzer.dll as an , +# with an *.owen-facts.json AdditionalFile, and (variant 2) +# /abs/path/libowen_e2.so + +``` + +**Variant 1: `DllImport` with `DllImportSearchPath.AssemblyDirectory`.** +- **Result:** `DllNotFoundException` on every build, including after `dotnet build-server shutdown`. +- **Why:** Roslyn shadow-copies analyzer assemblies and does not copy native files beside them. + +**Variant 2: an explicit path, `CompilerVisibleProperty`, and `NativeLibrary` reached by reflection.** +- **The finding arrives:** `C8.cs(18,1): warning OWNE2: OWN002: …`, identical across three builds through the compiler server and after a server restart. +- **A replaced native file breaks loading:** after the native file was overwritten with garbage under a live server, loading failed. It did not fall back to a stale copy. +- **`NativeLibrary` exists only on .NET-hosted compilers.** A .NET Framework compiler host has none. diff --git a/experiments/owen-e2-native-analyzer/analyzer/E2.Analyzer.csproj b/experiments/owen-e2-native-analyzer/analyzer/E2.Analyzer.csproj new file mode 100644 index 00000000..3c574e37 --- /dev/null +++ b/experiments/owen-e2-native-analyzer/analyzer/E2.Analyzer.csproj @@ -0,0 +1,13 @@ + + + netstandard2.0 + 12 + enable + true + true + $(NoWarn);RS2008;RS1035 + + + + + diff --git a/experiments/owen-e2-native-analyzer/analyzer/NativeOwenAnalyzer.cs b/experiments/owen-e2-native-analyzer/analyzer/NativeOwenAnalyzer.cs new file mode 100644 index 00000000..e0e14f7e --- /dev/null +++ b/experiments/owen-e2-native-analyzer/analyzer/NativeOwenAnalyzer.cs @@ -0,0 +1,149 @@ +// E2 spike (OX-01): a Roslyn DiagnosticAnalyzer that hands an OwnIR facts document to the +// Rust core IN PROCESS (P/Invoke into owen_e2) and reports what the core renders. The C# side +// only maps `file(line): warning CODE: text` onto a Location: no semantics here. +// +// Facts come from an AdditionalFile named *.owen-facts.json — an analyzer cannot run the +// extractor (a separate program reading files), which is itself one of the findings. + +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Owen.E2 +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class NativeOwenAnalyzer : DiagnosticAnalyzer + { + private static readonly DiagnosticDescriptor Finding = new("OWNE2", "Owen finding (E2 spike)", "{0}", + "Owen.E2", DiagnosticSeverity.Warning, true); + + private static readonly DiagnosticDescriptor LoadFailed = new("OWNE2LOAD", "Owen native core did not load", + "{0}", "Owen.E2", DiagnosticSeverity.Warning, true); + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Finding, LoadFailed); + + [DllImport("owen_e2", CallingConvention = CallingConvention.Cdecl)] + [DefaultDllImportSearchPaths(DllImportSearchPath.AssemblyDirectory)] + private static extern unsafe IntPtr owen_e2_check(byte* facts, UIntPtr len, byte* output, UIntPtr cap); + + // Variant 2: the shadow copy breaks DllImport's assembly-directory probing, so the + // project tells the analyzer where the native core is (CompilerVisibleProperty + // OwenNativeCore) and it is loaded by absolute path through NativeLibrary — which + // exists only on .NET (Core), so it is reached by reflection from netstandard2.0. + private delegate IntPtr CheckFn(IntPtr facts, UIntPtr len, IntPtr output, UIntPtr cap); + + private static CheckFn? LoadByPath(string path, out string? error) + { + error = null; + var nl = Type.GetType("System.Runtime.InteropServices.NativeLibrary, System.Runtime.InteropServices", throwOnError: false) + ?? Type.GetType("System.Runtime.InteropServices.NativeLibrary", throwOnError: false); + if (nl is null) + { + error = $"no NativeLibrary on this runtime ({RuntimeInformation.FrameworkDescription})"; + return null; + } + var load = nl.GetMethod("Load", new[] { typeof(string) }); + var export = nl.GetMethod("GetExport", new[] { typeof(IntPtr), typeof(string) }); + try + { + var handle = (IntPtr)load!.Invoke(null, new object[] { path })!; + var fn = (IntPtr)export!.Invoke(null, new object[] { handle, "owen_e2_check" })!; + return Marshal.GetDelegateForFunctionPointer(fn); + } + catch (System.Reflection.TargetInvocationException ex) + { + error = $"{ex.InnerException?.GetType().Name}: {ex.InnerException?.Message}"; + return null; + } + } + + private static readonly Regex Line = new(@"^(?.+?)\((?\d+)\): warning (?\w+): (?.*)$"); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationAction(Run); + } + + private static unsafe void Run(CompilationAnalysisContext ctx) + { + foreach (var file in ctx.Options.AdditionalFiles.Where(f => f.Path.EndsWith(".owen-facts.json", StringComparison.Ordinal))) + { + var bytes = Encoding.UTF8.GetBytes(file.GetText(ctx.CancellationToken)?.ToString() ?? ""); + string rendered; + ctx.Options.AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.OwenNativeCore", out var nativePath); + if (!string.IsNullOrEmpty(nativePath)) + { + var fn = LoadByPath(nativePath!, out var why); + if (fn is null) + { + ctx.ReportDiagnostic(Diagnostic.Create(LoadFailed, Location.None, why ?? "?")); + return; + } + var buffer = new byte[64 * 1024]; + long got; + fixed (byte* f = bytes) + fixed (byte* o = buffer) + got = (long)fn((IntPtr)f, (UIntPtr)bytes.Length, (IntPtr)o, (UIntPtr)buffer.Length); + if (got < 0 || got > buffer.Length) + { + ctx.ReportDiagnostic(Diagnostic.Create(LoadFailed, Location.None, $"core refused the facts ({got})")); + continue; + } + rendered = Encoding.UTF8.GetString(buffer, 0, (int)got); + Report(ctx, rendered); + continue; + } + try + { + var output = new byte[64 * 1024]; + long need; + fixed (byte* f = bytes) + fixed (byte* o = output) + need = (long)owen_e2_check(f, (UIntPtr)bytes.Length, o, (UIntPtr)output.Length); + if (need < 0 || need > output.Length) + { + ctx.ReportDiagnostic(Diagnostic.Create(LoadFailed, Location.None, $"core refused the facts ({need})")); + continue; + } + rendered = Encoding.UTF8.GetString(output, 0, (int)need); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException or BadImageFormatException) + { + ctx.ReportDiagnostic(Diagnostic.Create(LoadFailed, Location.None, $"{ex.GetType().Name}: {ex.Message}")); + return; + } + Report(ctx, rendered); + } + } + + private static void Report(CompilationAnalysisContext ctx, string rendered) + { + { + foreach (var raw in rendered.Split('\n')) + { + var m = Line.Match(raw); + if (!m.Success) + continue; + var tree = ctx.Compilation.SyntaxTrees.FirstOrDefault(t => t.FilePath.Replace('\\', '/').EndsWith("/" + m.Groups["file"].Value, StringComparison.Ordinal)); + var location = Location.None; + if (tree is not null) + { + var line = int.Parse(m.Groups["line"].Value) - 1; + var text = tree.GetText(ctx.CancellationToken); + if (line >= 0 && line < text.Lines.Count) + location = Location.Create(tree, text.Lines[line].Span); + } + ctx.ReportDiagnostic(Diagnostic.Create(Finding, location, $"{m.Groups["code"].Value}: {m.Groups["text"].Value}")); + } + } + } + } +} diff --git a/experiments/owen-e2-native-analyzer/native/Cargo.toml b/experiments/owen-e2-native-analyzer/native/Cargo.toml new file mode 100644 index 00000000..1481eef6 --- /dev/null +++ b/experiments/owen-e2-native-analyzer/native/Cargo.toml @@ -0,0 +1,18 @@ +# E2 feasibility spike (OX-01): the authoritative Rust core behind a C ABI, for a Roslyn +# analyzer to call in-process. Experimental, outside every shipping path and every CI build; +# its own workspace so the main workspace's fmt/clippy/test never see it. +[package] +name = "owen-e2-native" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +name = "owen_e2" +crate-type = ["cdylib"] + +[dependencies] +own-ir = { path = "../../../rust/crates/own-ir" } +own-bridge = { path = "../../../rust/crates/own-bridge" } + +[workspace] diff --git a/experiments/owen-e2-native-analyzer/native/src/lib.rs b/experiments/owen-e2-native-analyzer/native/src/lib.rs new file mode 100644 index 00000000..628d6fdd --- /dev/null +++ b/experiments/owen-e2-native-analyzer/native/src/lib.rs @@ -0,0 +1,29 @@ +//! E2 spike: `owen_e2_check(facts) -> msbuild lines`, the SAME `check_facts` + `render_finding` +//! the `own-cli ownir --format msbuild` path runs. No semantics of its own. + +use std::slice; + +/// Check one OwnIR facts document and write the msbuild-rendered findings (UTF-8, one per +/// line) into `out`. Returns the number of bytes needed: when it exceeds `cap`, nothing usable +/// was written and the caller retries with a larger buffer. A negative value is a refusal +/// (the facts did not load or the core refused them). +/// +/// # Safety +/// `facts` must point to `len` readable bytes and `out` to `cap` writable bytes. +#[no_mangle] +pub unsafe extern "C" fn owen_e2_check(facts: *const u8, len: usize, out: *mut u8, cap: usize) -> isize { + let bytes = unsafe { slice::from_raw_parts(facts, len) }; + let Ok(text) = std::str::from_utf8(bytes) else { return -1 }; + let Ok(doc) = own_ir::OwnIr::from_json(text) else { return -2 }; + let Ok(findings) = own_bridge::check_facts(&doc) else { return -3 }; + let mut rendered = String::new(); + for f in &findings { + rendered.push_str(&own_bridge::render_finding(f, "msbuild", "warning")); + rendered.push('\n'); + } + let need = rendered.len(); + if need <= cap { + unsafe { std::ptr::copy_nonoverlapping(rendered.as_ptr(), out, need) }; + } + need as isize +} From 5479a516677cefb36c89b281c10cfa194bfa52c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:02:13 +0000 Subject: [PATCH 5/5] docs(owen-ext): OX-01 report and verdict (GO_OWEN_EXTENSION_ALPHA) Official runs (local clean worktree 55/55; server CI Linux and Windows 55/55 each on 48c71e6, the first Windows run failing on a gate path bug fixed in 89bdb86), counts, mutations, kept TB-MVP guarantees, foundation diff, Owen.Cli payload, Snipper/diagnostics/IDE summaries and findings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011ZFvhLx1fM9Gerg4dKsZcL --- docs/notes/owen-extension-alpha-report.md | 169 ++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docs/notes/owen-extension-alpha-report.md diff --git a/docs/notes/owen-extension-alpha-report.md b/docs/notes/owen-extension-alpha-report.md new file mode 100644 index 00000000..6c21037c --- /dev/null +++ b/docs/notes/owen-extension-alpha-report.md @@ -0,0 +1,169 @@ +# OX-01 — Owen extension substrate with Typed Builder as extension #1: report + +**VERDICT: `GO_OWEN_EXTENSION_ALPHA`.** + +| | commit | +|---|---| +| base (`main`, TB-MVP-01 merged, #391) | `88cb8cc31c49d730290613208947102d0737cad2` | +| preregistration + Snipper audit | `49f5493` ([`owen-extension-alpha-preregistration.md`](owen-extension-alpha-preregistration.md)) | +| implementation + Amendment 1 (before any official run) | `6e65bfb` | +| gate fix (Windows: paths on two drives; gate mechanics only) | `89bdb86` | +| notes, E2 spike | `48c71e6` | +| this report | the head of the PR | + +## What a user does now + +``` +dotnet new web -n OrderBackend +dotnet add package Owen.TypedBuilder --version 0.1.0 +dotnet build +``` + +1. **Generation.** The typed API is generated on every compilation by a Roslyn incremental generator: `DraftOrder.Submit`, `OrderProtocol.WithDraft`, `Order.Create().Customer(…).Build()`. +2. **Compiler errors.** An illegal transition is a compiler error: `CS1061 'DraftOrder' does not contain a definition for 'Approve'`. +3. **Owen findings on build.** A stale state token is an Owen finding, reported by the build in the canonical MSBuild form and so in Visual Studio's Error List: + `…/C8_stale_draft.cs(18): warning OWN002: …` + It is a `warning` by default and an `error` with `OwenSeverity=error`. +4. **The project names its extensions** in `obj/owen/extensions.json`. + +None of it needs Python, a Rust toolchain, a global `owen`, a checkout, an environment +variable, or a PATH lookup of an Owen binary. + +## Architecture as built + +``` +Owen.TypedBuilder (package, extension #1) + ├─ analyzers/: Owen.TypedBuilder.Generator — Roslyn incremental generator + │ (shares TypedBuilderCore.cs with the CLI generator: one model, one renderer) + ├─ build*/: owen-extension.json + item + └─ depends on ─────────────────────────────┐ + ▼ +Owen.Build (package, the GENERIC host: names no extension) + ├─ build*/Owen.Build.targets: after the build → `owen build-check` + └─ tools/net8.0/any/: the Owen.Cli program + bundled extractor + rust-core//own-cli + │ + ▼ + the authoritative Rust core (`own-cli ownir --format msbuild`) +``` + +- **No `Owen.Toolchain` or `Owen.CoreAssets`.** `Owen.Build` is the one host package. It exists because a tool package cannot be a `PackageReference` (NU1212, observed) and because every extension must share one engine. +- **The platform inventory** lives in one `frontend/roslyn/OwenRustCore.props`, imported by both `Owen.Cli` and `Owen.Build`. +- **The host entry point** is a new `owen build-check` subcommand of the same program. It reuses `owen check`'s extractor and engine runner; it neither copies them nor renders findings. +- **The contract:** [`spec/OwenExtension.md`](../../spec/OwenExtension.md) and its JSON schema. +- **Generator delivery: A**, a Roslyn incremental generator, chosen on measurement (preregistration K-3). B was absent from the design-time build that Visual Studio's IntelliSense uses. + +## Official runs + +| run | where | result | +|---|---|---| +| `scripts/owen_extension_gate.py` from a fresh `git worktree` of `6e65bfb` (no `bin/`/`obj/`) | local Linux x64 | **55/55 PASS** | +| the same gate on `6e65bfb` | server CI `owen-extension`, ubuntu-latest | **55/55 PASS** | +| the same gate on `6e65bfb` | server CI `owen-extension`, windows-latest | **FAIL before any check.** The gate's own outside-checkout test called `os.path.commonpath` on a `D:` checkout and a `C:` temp directory and raised. Fixed in `89bdb86`, gate mechanics only. | +| the same gate on `48c71e6` | server CI `owen-extension`, ubuntu-latest | **55/55 PASS** | +| the same gate on `48c71e6` | server CI `owen-extension`, windows-latest | **55/55 PASS**: the `win-x64` core, absolute `C:\…\C8_stale_draft.cs(18)` origins, Exec through `cmd` | +| server CI run 37149198078 (`48c71e6`) | all jobs | **36/36 success**, including `owen CLI (gate A)` on both platforms, `state protocols` on both, lint, tests ×3, rust, formal kernel | + +## Counts (Linux official run) + +| | | +|---|---| +| packages | 2 shipped (`Owen.Build`, `Owen.TypedBuilder`) + 1 synthetic test extension | +| isolation | consumer PATH = the `dotnet` dir + `sh` only; `python`/`python3`/`py`/`cargo`/`rustc`/`owen` resolve to nothing; fresh `HOME`/`NUGET_PACKAGES`; `Owen.*` mapped to the candidate feed only; the consumer outside the checkout; no checkout path in any build output | +| A | build ok; the generated `Order.Protocol.g.cs` = `#nullable enable\n` + the committed golden (6880 bytes); the manifest lists `Owen.TypedBuilder 0.1.0` with its 4 capabilities; the clean sample has no Owen diagnostic | +| B | `draft.Approve(…)` → CS1061 `'Approve'` | +| C | `OwenSeverity=error` → `…/C8_stale_draft.cs(18): error OWN002: …`, build fails | +| D | the origin is the **absolute** path of the staged file, line 18; the default severity gives the same line as `warning OWN002`, and the build succeeds | +| F | **30/30** corpus cases through the package: compiler-stage CS codes, extractor refusals as `OWENB010`, core refusals as `OWENB011`, verdicts `OWN002`/`OWN005`/`OWN013`, positives clean, the P5/P8 `proven_call` present, K1 `OWN001`, K2 clean | +| G | the TB-MVP acceptance runner against the package-built consumer: **44/44**, transcript **byte-identical** to `samples/OrderBackend/evidence/acceptance.txt` | +| H | `Owen.TestExtension` beside `Owen.TypedBuilder`: the manifest lists both, and the project is analysed once (1 `OWN002`) | +| M1a | the declared descriptor deleted → `OWENB002`, no `OWN002` | +| M1b | the whole declaration deleted → `OWENB001` ("no Owen extension is active"), no `OWN002` | +| M2 | unknown capability `teleportation` → `OWENB004` | +| M3 | the bundled `own-cli` deleted → `OWENB005` (never a clean build) | +| M4 | `requires.ownir: 3` → `OWENB003`; `requires.host: 9.0.0` → `OWENB003` | +| M5 | the host target emptied → no `OWN002`, so the C check catches the absence | +| M6 | consumer with no checkout → E holds | +| M7 | the host sources (`Owen.Build/**`, `BuildCheckCommand.cs`, `OwenRustCore.props`) contain `TypedBuilder` 0 times | + +## Existing guarantees kept + +On the implementation commit: +- `typed_builder_gate.py --rust`: 8/20/2 corpus, 44 checks ×2, transcript `ba447304cdf957ee`, 0 failures. The CLI generator now runs on the shared core and still writes the golden byte for byte. +- `protocol_gate.py --rust`: 0 failures, 29 documents byte-identical on both CLIs. +- `heap_effects_gate.py`: unchanged; the extractor is untouched. +- `ruff` and `mypy` clean. + +**`tests/run_tests.py` locally:** +- `fresh-record-valid` fails only on a dirty tree, by design. +- Two stage-1 controls (`divergence-is-5`, `exec-failure-is-5`) fail **on the owen surface only**, and only once a launcher is built locally. In the base run they were skipped ("no built launcher"). A launcher built from the **base** commit `88cb8cc3` fails them identically, so this is pre-existing and not caused by this change. The server CI is authoritative for it. + +**Foundations.** +`git diff --stat 88cb8cc3..HEAD -- ownlang rust spec/OwnIR.md spec/Bridge.md spec/ownir.schema.json frontend/roslyn/OwnSharp.Extractor docs/evidence/calibration scripts/perf_baseline.py` +is **empty**. No OwnIR, ownership, state-protocol, H0/H1, T0, P-022 harness, `OWN` code or core +change. + +**`Owen.Cli` payload** (Amendment 1.2), measured against a pack of the base commit with the +same staged core: +- identical **file list**, 109 entries; +- every Roslyn assembly, `deps.json`/`runtimeconfig.json`, vendored `.py` and the Rust core **byte-identical**; +- only `ownsharp.dll`/`.pdb` (gains `build-check`), `ownsharp-extract.dll`/`.pdb` (commit-stamped build metadata; source unchanged) and the nuspec `repository commit` differ. + +Gate A (`owen-cli` install → `owen check` → `OWN001`) runs in server CI on both platforms. + +## Snipper salvage (P1) + +[`owen-extension-snipper-salvage.md`](owen-extension-snipper-salvage.md), read-only at `43b395a`, MIT. **For this slice:** +- **REJECT** its binary discovery (setting → bundled → PATH with silent fall-through), the long-lived VS process lifecycle, and the Roslyn sidecar. +- **ADAPT** pack-time bundling with a hard fail, already `OwenRustCore.props`, and one-shot child discipline. MSBuild's Exec kills the child tree on cancellation; `owen` drains both streams and maps exit codes. +- **Later only:** StreamJsonRpc framing (COPY), mocked-VS and two-pipe fake-server tests, the thin VS Code client shape. + +## Diagnostics model (P8) + +[`owen-diagnostics-model.md`](owen-diagnostics-model.md). The model already exists: +`own_bridge::Finding` → `render_finding` (human/github/msbuild) / `build_sarif`. No new layer +was built. The host only absolutises the origin path. + +**Recorded gaps, not changed:** +- the msbuild line has no column although `Finding` has one; +- the state-protocol wording and line (#393). + +## IDE (P9/P10) + +[`owen-extension-ide-feasibility.md`](owen-extension-ide-feasibility.md): +- **E1 (generic VSIX): NOT EXECUTED.** There is no Windows or Visual Studio here. It is specified, with its two blockers: extractor overlay input for unsaved buffers, and a resident process. +- **E2 (native Roslyn analyzer): REJECT.** Measured on Linux: + - shadow copy defeats default native resolution; + - only an explicit path through reflection-reached `NativeLibrary` works, and only on .NET-hosted compilers; + - the extractor cannot run inside an analyzer. +- **Invariant:** one `Owen.VisualStudio`, one Owen server, extension ≠ IDE plugin. + +## Findings worth carrying forward + +1. **Top-level statements.** A region in top-level statements is not modelled by the extractor. The TB-MVP acceptance runner has one, and the host refuses it with `OWENB010` instead of skipping it, which is correct fail-closed behaviour. The gate builds that runner with `OwenEnabled=false`. +2. **`buildTransitive` reach.** It makes every project that references an Owen-enabled project Owen-enabled too. That is intended (the same analysis everywhere) and documented. A project opts out with `OwenEnabled=false`. +3. **A system shell.** MSBuild's `Exec` needs a system shell (`sh` on Unix, `cmd` on Windows). That is not a toolchain dependency, but the isolation harness must provide it. +4. **Windows console encoding.** On Windows the core's em dash in a refusal text reaches the build log as `-`: `build-check` writes through the console encoding. The codes, locations and gate checks are unaffected. The fix is to emit UTF-8 explicitly (`Console.OutputEncoding` plus Exec `StdOutEncoding`); it is left for a follow-up, not changed after the official run. + +## Verdict + +**`GO_OWEN_EXTENSION_ALPHA`**, on `48c71e6`. Every condition registered for GO holds: + +| registered condition | evidence | +|---|---| +| `PackageReference Owen.TypedBuilder` → generated API | A on Linux and Windows: generated text = `#nullable enable` + golden; manifest written | +| → CS diagnostics | B, plus the 10 compiler-stage corpus cases | +| → OWN diagnostics on build | C/D, plus the core-stage corpus cases, as canonical, absolute, navigable MSBuild lines | +| → no external toolchain or setup | E: a stripped PATH, fresh HOME/NuGet, outside the checkout, no checkout path in any output, on both platforms | +| → generic extension discovery proven | H (two extensions, one analysis), M7 (the host names no extension), M1a/M1b/M2/M3/M4/M5 (fail loud) | +| existing TB-MVP guarantees | F (30/30 through the package), G (44/44, transcript byte-identical), `typed_builder_gate` unchanged | +| foundations unchanged | the foundation diff is empty; `protocol_gate`/`heap_effects_gate`/`cargo` green | +| Windows + Linux server CI | 36/36 | + +**After GO: STOP.** None of the following is started: +- a production VSIX, LSP or VS Code extension; +- the Memory or TypeDisciplines extensions; +- BCL summaries (#394), typed write targets (#395), affine tokens (#392), diagnostic wording (#393), concurrency (#396); +- a second aggregate; +- a repo-wide rename. + +**For publishing:** nothing was pushed to nuget.org. The license blocker that gates `Owen.Cli` gates these two packages too.