diff --git a/.gitattributes b/.gitattributes index 51ef9854..aeb43790 100644 --- a/.gitattributes +++ b/.gitattributes @@ -30,3 +30,4 @@ docs/evidence/*.json text eol=lf # `core.autocrlf=true` would make a correct generator look stale. samples/OrderBackend/OrderBackend/Domain/Order.Protocol.cs text eol=lf samples/OrderBackend/evidence/* text eol=lf +docs/notes/evidence/ox02-visual-studio/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 105c738a..479fc2d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3487,6 +3487,87 @@ jobs: - name: The extension gate (pack, isolated consumer A-H, mutations M1-M7) run: python scripts/owen_extension_gate.py + # OX-02 (docs/notes/owen-visual-studio-preregistration.md): live Owen diagnostics in the IDE. + # The extractor run in-process over an overlay is byte-identical to the command line; the + # `owen serve` service, as Owen.VisualStudio uses it, answers unsaved edits (K1-K8), agrees + # with `dotnet build` on saved sources (P10), stays inside its latency/memory budget, and its + # client half survives the service dying (K6). Both platforms: the VSIX host is Windows-only, + # the service is not. + owen-live: + name: Owen live service (in-process extractor, owen serve, build/live parity) + 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 extractor as a library (E1-E4) + run: python tests/check_extractor_in_process.py + - name: The live gate (L0, S, K1-K8, P10, T, C, G) + run: python scripts/owen_live_gate.py + + # OX-02 P14: the real Visual Studio run. The hosted image carries Visual Studio with the + # extension workload; the VSIX is built with its MSBuild, installed into a dedicated root + # suffix, and the IDE is driven (DTE for edits, UI Automation for the Error List, Owen's + # opt-in trace for the squiggles) on a solution that is restored and never built. + owen-visual-studio: + name: Owen.VisualStudio in a real Visual Studio (unsaved edit -> squiggle + Error List) + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.13" + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master, 2026-07-10 + with: + toolchain: stable + - name: Build the Rust core + shell: bash + run: cargo build -p own-cli --release --manifest-path rust/Cargo.toml + - name: Prepare the solution (packed Owen.Build + Owen.TypedBuilder; restored, not built) + shell: bash + run: python scripts/vs/prepare_vs_fixture.py --out "$RUNNER_TEMP/vsfx" --rust-core "$PWD/rust/target/release/own-cli.exe" + - name: Build the VSIX + shell: powershell + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $msbuild = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1 + & $msbuild frontend\roslyn\Owen.VisualStudio\Owen.VisualStudio.csproj /restore /p:Configuration=Release /p:DeployExtension=false /v:m + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $built = @(Get-ChildItem -Recurse frontend\roslyn\Owen.VisualStudio -Filter Owen.VisualStudio.vsix) + if ($built.Count -eq 0) { Write-Error "no Owen.VisualStudio.vsix was produced"; exit 1 } + $built | ForEach-Object { "$($_.FullName) ($($_.Length) bytes)" } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead($built[0].FullName) + $zip.Entries | ForEach-Object { " entry: $($_.FullName) ($($_.Length))" } + $m = $zip.GetEntry('extension.vsixmanifest'); $r = New-Object System.IO.StreamReader($m.Open()); $r.ReadToEnd(); $r.Dispose(); $zip.Dispose() + New-Item -ItemType Directory -Force "$env:RUNNER_TEMP\vs-evidence" | Out-Null + Copy-Item $built[0].FullName "$env:RUNNER_TEMP\vs-evidence\" + - name: The acceptance in Visual Studio + shell: powershell + run: | + $vsix = (Get-ChildItem -Recurse frontend\roslyn\Owen.VisualStudio -Filter Owen.VisualStudio.vsix | Select-Object -First 1).FullName + scripts\vs\owen-vs-acceptance.ps1 -Vsix $vsix -Fixture "$env:RUNNER_TEMP\vsfx\fixture.json" -Out "$env:RUNNER_TEMP\vs-evidence" -RootSuffix Exp + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: owen-visual-studio-evidence + path: ${{ runner.temp }}/vs-evidence/ + # 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/howto-visual-studio.md b/docs/howto-visual-studio.md index 823de48b..a3fe83bb 100644 --- a/docs/howto-visual-studio.md +++ b/docs/howto-visual-studio.md @@ -58,6 +58,33 @@ so it never breaks a wrapping build by accident. ## 2. In Visual Studio +### Live: Owen.VisualStudio (OX-02) — for projects that use an Owen extension + +If the project references an Owen extension package (for example `Owen.TypedBuilder`, which +brings `Owen.Build`), install the one generic **Owen** VSIX +(`frontend/roslyn/Owen.VisualStudio`, built on Windows with Visual Studio's MSBuild: +`msbuild frontend\roslyn\Owen.VisualStudio\Owen.VisualStudio.csproj /restore /p:Configuration=Release`, +then double-click `Owen.VisualStudio.vsix`). Requirements: Visual Studio 2022 17.14+ or Visual +Studio 2026, and the .NET 8+ runtime that `Owen.Build` already needs. No Python, Rust, checkout +or global `owen` tool. + +What you get, while typing and without saving or building: +- Owen's findings as **squiggles**: at the finding's location, and on each witness step the core reports (for a stale state token, the line that uses it again); +- one **Error List** row per finding (Source/Tool `Owen`), double-click to navigate; CS diagnostics stay the compiler's; +- the **Owen** output pane: the live service's own log, including its stderr. + +How it works and what it does not do: `docs/notes/owen-visual-studio-preregistration.md`. In +short, the VSIX sends the editor's text to `owen serve` from the project's own `Owen.Build` +package, and the Rust core decides; the VSIX decides nothing. `dotnet build` stays the +authoritative gate, with the same findings (the build/live parity gate holds them equal). + +Known limits: a state-protocol finding has no column (the squiggle covers the reported line); +a stale token is reported at the region entry with the stale use as its witness step (#393); a +file that was never saved is not part of the project's input set; live analysis starts after the +project's first design-time build (Visual Studio runs it on load). + +### Without an extension + Two approaches, cheapest first. ### Option A — External Tool (on-demand, no build coupling) — recommended diff --git a/docs/notes/alpha-readiness.md b/docs/notes/alpha-readiness.md index e4bed9f7..8fb7b70a 100644 --- a/docs/notes/alpha-readiness.md +++ b/docs/notes/alpha-readiness.md @@ -135,3 +135,17 @@ until the .NET alpha above is delicious. Do not let the spike exceed 20%. The standing priority is unchanged: **prove value, don't reshape form** ([consolidation-and-positioning.md](consolidation-and-positioning.md)). This note is the concrete "what value, packaged how" gate for that. + +## Update 2026-10-04: live diagnostics in Visual Studio (OX-02) + +`GO_OWEN_VISUAL_STUDIO_ALPHA` (docs/notes/owen-visual-studio-report.md). One generic +`Owen.VisualStudio` VSIX shows every active Owen extension's findings live, unsaved, as +squiggles and Error List rows. It reads the project's `obj/owen/live.txt`, written by +Owen.Build in design-time builds. It talks to `owen serve` from the project's own Owen.Build +package. The extractor runs in-process over the editor's text, and the verdict is the Rust +core's. + +- Proven in a real Visual Studio 2026 on CI: 19/19. +- Build/live parity is a gate. +- Extensions ship no IDE code. +- `dotnet build` stays the authoritative gate. diff --git a/docs/notes/evidence/ox02-visual-studio/README.md b/docs/notes/evidence/ox02-visual-studio/README.md new file mode 100644 index 00000000..06d4cda4 --- /dev/null +++ b/docs/notes/evidence/ox02-visual-studio/README.md @@ -0,0 +1,13 @@ +# OX-02 real Visual Studio evidence + +From CI run 37175674769 (job `owen-visual-studio`, head `fbdc978`), Visual Studio Enterprise +2026 18.10.12217.157 on the hosted `windows-2025-vs2026` image, driven by +`scripts/vs/owen-vs-acceptance.ps1`. Kept byte-for-byte as uploaded. + +- `acceptance.log`: every check, 19/19 passed. +- `results.json`: the checks and the measurements (edit -> squiggle / disappearance per edit, UI-thread maximum, service timings, working set). +- `trace.jsonl`: Owen.VisualStudio's own trace (`OWEN_LIVE_TRACE`): load, workspace snapshots (unsaved `Use.cs` + Roslyn's in-memory generated documents), publications with the service's timings, the tags the tagger produced per text version, UI-thread maxima. +- `k2-squiggle.png`: `Use.cs*` unsaved; squiggles on `OrderProtocol.WithDraft(order, draft =>` (the finding) and on the inserted `draft.Submit(now);` (its witness step); the Error List row `OWN002 … LiveFixture Use.cs 10`. +- `k2-navigated.png`: after double-clicking the row: caret at 10:9. +- `p9-cs-and-own.png`: CS0029 and OWN002 side by side. +- `k3-gone.png`: after deleting the line: no squiggle, no row. diff --git a/docs/notes/evidence/ox02-visual-studio/acceptance.log b/docs/notes/evidence/ox02-visual-studio/acceptance.log new file mode 100644 index 00000000..f0244cdb --- /dev/null +++ b/docs/notes/evidence/ox02-visual-studio/acceptance.log @@ -0,0 +1,26 @@ +[04:03:20.333] Visual Studio: Visual Studio Enterprise 2026 18.10.12217.157 [b61ad8f0] +[04:06:52.917] ok[install-vsix]: VSIXInstaller exit 0 into root suffix Exp +[04:06:52.954] dropped the MEF cache C:\Users\runneradmin\AppData\Local\Microsoft\VisualStudio\18.0_b61ad8f0Exp\ComponentModelCache +[04:07:48.331] devenv /updateconfiguration exit 0 +[04:07:48.396] devenv pid 1812 +[04:07:58.313] solution open after 9.893 s: D:\a\_temp\vsfx\ws\LiveFixture.sln +[04:08:09.801] active document: D:\a\_temp\vsfx\ws\LiveFixture\Use.cs +[04:08:10.954] ok[vsix-loaded]: Owen.VisualStudio composed in devenv: {"t":1791086890317,"kind":"log","data":{"line":"Owen live analysis loaded"}} +[04:08:11.024] ok[L0-live-request]: the design-time build wrote D:\a\_temp\vsfx\ws\LiveFixture\obj\owen\live.txt (no build was run) +[04:08:30.432] ok[K1-open-clean]: first publication (version 2, 4921 ms in the service): 0 entries, OWN: 0 +[04:08:30.437] snapshot: documents D:\a\_temp\vsfx\ws\LiveFixture\Use.cs; generated D:\a\_temp\vsfx\ws\LiveFixture\obj\Debug\net8.0\generated\Owen.TypedBuilder.Generator\Own.TypedBuilder.TypedBuilderGenerator\Order.Protocol.g.cs, D:\a\_temp\vsfx\ws\LiveFixture\obj\Debug\net8.0\generated\Owen.TypedBuilder.Generator\Own.TypedBuilder.TypedBuilderGenerator\Owen.TypedBuilder.Attributes.g.cs +[04:08:31.460] ok[K1-unsaved-clean]: a clean unsaved edit was analysed (version 3) with no OWN entry; document saved=False +[04:08:31.963] ok[K2-squiggle]: tags on Use.cs after the edit: 10:9+39 primary=True 'OrderProtocol.WithDraft(order, draft =>'; 13:13+18 primary=False 'draft.Submit(now);' +[04:08:31.967] ok[K2-unsaved]: Use.cs is unsaved (Saved=False); the disk still has one Submit: True +[04:08:33.309] ok[K2-error-list]: Error List row: OWN002 | IDisposable local 'draft' is used after it is disposed [resource: disposable] | LiveFixture | Use.cs | 10 +[04:08:34.933] ok[K2-navigation]: double-click -> 'D:\a\_temp\vsfx\ws\LiveFixture\Use.cs' line 10 column 9; expected 'D:\a\_temp\vsfx\ws\LiveFixture\Use.cs' line 10 column 9 (the core's location, §6) +[04:08:36.475] ok[P9-cs-and-own]: CS0029 row: CS0029 | Cannot implicitly convert type 'string' to 'int' | LiveFixture | Use.cs | 10; OWN002 rows: 1 +[04:08:40.340] ok[K3-disappears]: after deleting the line: tags without OWN002 at +344 ms; Error List row gone: True +[04:09:04.176] ok[T-edit-to-squiggle]: edit -> squiggle over 10/10 edits: p95 349 ms (threshold 1000), all: 334, 328, 349, 330, 336, 330, 347, 343, 328, 335 +[04:09:04.179] ok[T-edit-to-disappearance]: edit -> disappearance over 10/10 edits: p95 347 ms (threshold 1000), all: 328, 347, 330, 328, 336, 329, 322, 342, 343, 326 +[04:09:04.194] ok[T-ui-thread]: longest Owen UI-thread handler: 3.3251 ms (threshold 50) +[04:09:04.202] ok[no-build]: no LiveFixture.dll under bin/ (0), no build-check request: the build host never ran +[04:09:04.204] ok[never-saved]: Use.cs on disk is byte-identical to the fixture +[04:09:04.556] owen serve processes before devenv ends: 5372 +[04:09:07.578] ok[no-orphan]: owen serve ran (1 process(es)) and did not outlive a killed devenv (0 left) +[04:09:07.580] VS acceptance: all checks passed diff --git a/docs/notes/evidence/ox02-visual-studio/k2-navigated.png b/docs/notes/evidence/ox02-visual-studio/k2-navigated.png new file mode 100644 index 00000000..5b6d86c8 Binary files /dev/null and b/docs/notes/evidence/ox02-visual-studio/k2-navigated.png differ diff --git a/docs/notes/evidence/ox02-visual-studio/k2-squiggle.png b/docs/notes/evidence/ox02-visual-studio/k2-squiggle.png new file mode 100644 index 00000000..df90c2d7 Binary files /dev/null and b/docs/notes/evidence/ox02-visual-studio/k2-squiggle.png differ diff --git a/docs/notes/evidence/ox02-visual-studio/k3-gone.png b/docs/notes/evidence/ox02-visual-studio/k3-gone.png new file mode 100644 index 00000000..d7cc4348 Binary files /dev/null and b/docs/notes/evidence/ox02-visual-studio/k3-gone.png differ diff --git a/docs/notes/evidence/ox02-visual-studio/p9-cs-and-own.png b/docs/notes/evidence/ox02-visual-studio/p9-cs-and-own.png new file mode 100644 index 00000000..2ce7f474 Binary files /dev/null and b/docs/notes/evidence/ox02-visual-studio/p9-cs-and-own.png differ diff --git a/docs/notes/evidence/ox02-visual-studio/results.json b/docs/notes/evidence/ox02-visual-studio/results.json new file mode 100644 index 00000000..d65cf575 --- /dev/null +++ b/docs/notes/evidence/ox02-visual-studio/results.json @@ -0,0 +1,153 @@ +{ + "checks": [ + { + "name": "install-vsix", + "ok": true, + "detail": "VSIXInstaller exit 0 into root suffix Exp" + }, + { + "name": "vsix-loaded", + "ok": true, + "detail": "Owen.VisualStudio composed in devenv: {\"t\":1791086890317,\"kind\":\"log\",\"data\":{\"line\":\"Owen live analysis loaded\"}}" + }, + { + "name": "L0-live-request", + "ok": true, + "detail": "the design-time build wrote D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\owen\\live.txt (no build was run)" + }, + { + "name": "K1-open-clean", + "ok": true, + "detail": "first publication (version 2, 4921 ms in the service): 0 entries, OWN: 0" + }, + { + "name": "K1-unsaved-clean", + "ok": true, + "detail": "a clean unsaved edit was analysed (version 3) with no OWN entry; document saved=False" + }, + { + "name": "K2-squiggle", + "ok": true, + "detail": "tags on Use.cs after the edit: 10:9+39 primary=True \u0027OrderProtocol.WithDraft(order, draft =\u003e\u0027; 13:13+18 primary=False \u0027draft.Submit(now);\u0027" + }, + { + "name": "K2-unsaved", + "ok": true, + "detail": "Use.cs is unsaved (Saved=False); the disk still has one Submit: True" + }, + { + "name": "K2-error-list", + "ok": true, + "detail": "Error List row: OWN002 | IDisposable local \u0027draft\u0027 is used after it is disposed [resource: disposable] | LiveFixture | Use.cs | 10" + }, + { + "name": "K2-navigation", + "ok": true, + "detail": "double-click -\u003e \u0027D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs\u0027 line 10 column 9; expected \u0027D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs\u0027 line 10 column 9 (the core\u0027s location, §6)" + }, + { + "name": "P9-cs-and-own", + "ok": true, + "detail": "CS0029 row: CS0029 | Cannot implicitly convert type \u0027string\u0027 to \u0027int\u0027 | LiveFixture | Use.cs | 10; OWN002 rows: 1" + }, + { + "name": "K3-disappears", + "ok": true, + "detail": "after deleting the line: tags without OWN002 at +344 ms; Error List row gone: True" + }, + { + "name": "T-edit-to-squiggle", + "ok": true, + "detail": "edit -\u003e squiggle over 10/10 edits: p95 349 ms (threshold 1000), all: 334, 328, 349, 330, 336, 330, 347, 343, 328, 335" + }, + { + "name": "T-edit-to-disappearance", + "ok": true, + "detail": "edit -\u003e disappearance over 10/10 edits: p95 347 ms (threshold 1000), all: 328, 347, 330, 328, 336, 329, 322, 342, 343, 326" + }, + { + "name": "T-ui-thread", + "ok": true, + "detail": "longest Owen UI-thread handler: 3.3251 ms (threshold 50)" + }, + { + "name": "no-build", + "ok": true, + "detail": "no LiveFixture.dll under bin/ (0), no build-check request: the build host never ran" + }, + { + "name": "never-saved", + "ok": true, + "detail": "Use.cs on disk is byte-identical to the fixture" + }, + { + "name": "no-orphan", + "ok": true, + "detail": "owen serve ran (1 process(es)) and did not outlive a killed devenv (0 left)" + } + ], + "measurements": { + "visual_studio": "Visual Studio Enterprise 2026 18.10.12217.157", + "cold_open_to_first_publication_ms": 41686, + "k2_edit_to_squiggle_ms": 448, + "edit_to_squiggle_ms": [ + 334, + 328, + 349, + 330, + 336, + 330, + 347, + 343, + 328, + 335 + ], + "edit_to_disappearance_ms": [ + 328, + 347, + 330, + 328, + 336, + 329, + 322, + 342, + 343, + 326 + ], + "ui_thread_max_ms": 3.3251, + "service_total_ms": [ + 4921, + 134, + 150, + 75, + 63, + 60, + 60, + 57, + 55, + 76, + 76, + 58, + 61, + 57, + 59, + 58, + 56, + 56, + 59, + 55, + 71, + 54, + 55, + 55, + 58, + 55 + ], + "publications": 26, + "service_working_set_kb_first_last": [ + 173572, + 208184 + ] + }, + "failed": false +} diff --git a/docs/notes/evidence/ox02-visual-studio/trace.jsonl b/docs/notes/evidence/ox02-visual-studio/trace.jsonl new file mode 100644 index 00000000..e1dbb393 --- /dev/null +++ b/docs/notes/evidence/ox02-visual-studio/trace.jsonl @@ -0,0 +1,132 @@ +{"t":1791086890317,"kind":"log","data":{"line":"Owen live analysis loaded"}} +{"t":1791086890436,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":1,"tags":[]}} +{"t":1791086890439,"kind":"ui","data":{"handler":"GetTags","ms":3.3251,"pid":1812}} +{"t":1791086890931,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086891452,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"ProjectChanged"}} +{"t":1791086898758,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086898758,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086910023,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":2,"timing":{"extract":4833,"core":17,"total":4921,"working_set_kb":173572},"entries":[]}} +{"t":1791086910902,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":2,"tags":[]}} +{"t":1791086910909,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086911170,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086911310,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":3,"timing":{"extract":128,"core":5,"total":134,"working_set_kb":180732},"entries":[]}} +{"t":1791086911478,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":3,"tags":[]}} +{"t":1791086911499,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":4,"tags":[]}} +{"t":1791086911501,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086911505,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086911771,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086911932,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":4,"timing":{"extract":142,"core":6,"total":150,"working_set_kb":187428},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086911937,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":4,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086914995,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":5,"tags":[{"code":"OWN002","primary":true,"line":11,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":14,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086915008,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086915264,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086915346,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":5,"timing":{"extract":68,"core":6,"total":75,"working_set_kb":198164},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":11,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":14,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086916535,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":6,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086916538,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086916792,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086916861,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":6,"timing":{"extract":57,"core":5,"total":63,"working_set_kb":201784},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086919268,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":7,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086919270,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086919539,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086919604,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":7,"timing":{"extract":54,"core":5,"total":60,"working_set_kb":203612},"entries":[]}} +{"t":1791086919604,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":7,"tags":[]}} +{"t":1791086921256,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":8,"tags":[]}} +{"t":1791086921258,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086921514,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086921582,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":8,"timing":{"extract":53,"core":6,"total":60,"working_set_kb":204312},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086921582,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":8,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086922417,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":9,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086922419,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086922674,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086922736,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":9,"timing":{"extract":51,"core":6,"total":57,"working_set_kb":203332},"entries":[]}} +{"t":1791086922736,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":9,"tags":[]}} +{"t":1791086923622,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":10,"tags":[]}} +{"t":1791086923624,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086923881,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086923942,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":10,"timing":{"extract":49,"core":5,"total":55,"working_set_kb":203648},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086923943,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":10,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086924784,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":11,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086924786,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086925041,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086925122,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":11,"timing":{"extract":70,"core":5,"total":76,"working_set_kb":205408},"entries":[]}} +{"t":1791086925122,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":11,"tags":[]}} +{"t":1791086926005,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":12,"tags":[]}} +{"t":1791086926007,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086926265,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086926346,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":12,"timing":{"extract":68,"core":7,"total":76,"working_set_kb":208088},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086926348,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":12,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086927200,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":13,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086927202,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086927459,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086927522,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":13,"timing":{"extract":51,"core":5,"total":58,"working_set_kb":208936},"entries":[]}} +{"t":1791086927522,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":13,"tags":[]}} +{"t":1791086928377,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":14,"tags":[]}} +{"t":1791086928379,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086928629,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086928696,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":14,"timing":{"extract":52,"core":8,"total":61,"working_set_kb":205092},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086928701,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":14,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086929571,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":15,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086929573,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086929830,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086929891,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":15,"timing":{"extract":51,"core":5,"total":57,"working_set_kb":205204},"entries":[]}} +{"t":1791086929891,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":15,"tags":[]}} +{"t":1791086930734,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":16,"tags":[]}} +{"t":1791086930734,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086930993,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086931060,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":16,"timing":{"extract":52,"core":6,"total":59,"working_set_kb":205324},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086931062,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":16,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086931943,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":17,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086931945,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086932203,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086932265,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":17,"timing":{"extract":51,"core":6,"total":58,"working_set_kb":204592},"entries":[]}} +{"t":1791086932271,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":17,"tags":[]}} +{"t":1791086933166,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":18,"tags":[]}} +{"t":1791086933168,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086933426,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086933488,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":18,"timing":{"extract":49,"core":5,"total":56,"working_set_kb":201948},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086933489,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":18,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086934359,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":19,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086934361,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086934618,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086934680,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":19,"timing":{"extract":49,"core":5,"total":56,"working_set_kb":208228},"entries":[]}} +{"t":1791086934680,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":19,"tags":[]}} +{"t":1791086935531,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":20,"tags":[]}} +{"t":1791086935533,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086935807,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086935872,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":20,"timing":{"extract":52,"core":6,"total":59,"working_set_kb":208444},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086935872,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":20,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086936729,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":21,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086936731,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086936983,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086937042,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":21,"timing":{"extract":49,"core":5,"total":55,"working_set_kb":208520},"entries":[]}} +{"t":1791086937042,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":21,"tags":[]}} +{"t":1791086937886,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":22,"tags":[]}} +{"t":1791086937888,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086938144,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086938223,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":22,"timing":{"extract":62,"core":8,"total":71,"working_set_kb":206956},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086938223,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":22,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086939074,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":23,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086939076,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086939333,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086939408,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":23,"timing":{"extract":47,"core":6,"total":54,"working_set_kb":208952},"entries":[]}} +{"t":1791086939408,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":23,"tags":[]}} +{"t":1791086940291,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":24,"tags":[]}} +{"t":1791086940293,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086940552,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086940613,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":24,"timing":{"extract":48,"core":5,"total":55,"working_set_kb":208916},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086940613,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":24,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086941497,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":25,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086941501,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086941761,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086941819,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":25,"timing":{"extract":49,"core":5,"total":55,"working_set_kb":208860},"entries":[]}} +{"t":1791086941821,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":25,"tags":[]}} +{"t":1791086942649,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":26,"tags":[]}} +{"t":1791086942649,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086942908,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086942976,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":26,"timing":{"extract":51,"core":7,"total":58,"working_set_kb":207796},"entries":[{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":10,"column":9,"start":8,"end":47,"primary":true,"message":"IDisposable local 'draft' is used after it is disposed [resource: disposable]"},{"code":"OWN002","severity":"warning","file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","line":13,"column":13,"start":12,"end":30,"primary":false,"message":"used here after it was released/returned — IDisposable local 'draft' is used after it is disposed [resource: disposable]"}]}} +{"t":1791086942976,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":26,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"},{"code":"OWN002","primary":false,"line":13,"column":13,"length":18,"text":"draft.Submit(now);"}]}} +{"t":1791086943840,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":27,"tags":[{"code":"OWN002","primary":true,"line":10,"column":9,"length":39,"text":"OrderProtocol.WithDraft(order, draft =>"}]}} +{"t":1791086943842,"kind":"edit","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","kind":"DocumentChanged"}} +{"t":1791086944099,"kind":"snapshot","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","documents":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs"],"generated":["D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Order.Protocol.g.cs","D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\obj\\Debug\\net8.0\\generated\\Owen.TypedBuilder.Generator\\Own.TypedBuilder.TypedBuilderGenerator\\Owen.TypedBuilder.Attributes.g.cs"]}} +{"t":1791086944158,"kind":"publish","data":{"key":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\LiveFixture.csproj","version":27,"timing":{"extract":49,"core":5,"total":55,"working_set_kb":208184},"entries":[]}} +{"t":1791086944158,"kind":"tags","data":{"file":"D:\\a\\_temp\\vsfx\\ws\\LiveFixture\\Use.cs","snapshot":27,"tags":[]}} diff --git a/docs/notes/owen-extension-ide-feasibility.md b/docs/notes/owen-extension-ide-feasibility.md index 97c904c3..fb095255 100644 --- a/docs/notes/owen-extension-ide-feasibility.md +++ b/docs/notes/owen-extension-ide-feasibility.md @@ -20,7 +20,11 @@ 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 +**Update (OX-02): executed and shipped as `Owen.VisualStudio`; see owen-visual-studio-report.md +(GO). The two blockers below were solved by the in-process extractor with an overlay and by +`owen serve`.** + +**Verdict at OX-01: 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. diff --git a/docs/notes/owen-visual-studio-preregistration.md b/docs/notes/owen-visual-studio-preregistration.md new file mode 100644 index 00000000..f1794e0f --- /dev/null +++ b/docs/notes/owen-visual-studio-preregistration.md @@ -0,0 +1,150 @@ +# OX-02 preregistration: one generic `Owen.VisualStudio` host + +Fixed **before** any product code. A change to anything below is an **Amendment** appended at +the end, dated, with its reason; nothing above the amendments is edited after the commit. + +## 0. Base + +- Base: `main` = `e889f8b37f04f94446855f0c7924521d28d8bb94` (merge of #397, OX-01). Verified present: `frontend/roslyn/Owen.Build`, `frontend/roslyn/Owen.TypedBuilder`, `spec/OwenExtension.md`, `scripts/owen_extension_gate.py`, `OwnSharp.Cli/BuildCheckCommand.cs`, `frontend/roslyn/OwenRustCore.props`, `docs/notes/owen-extension-alpha-report.md` (verdict `GO_OWEN_EXTENSION_ALPHA`). +- Nothing in the areas OX-02 touches (extractor input, build host, Rust core renderers) moved since OX-01: the plan in `owen-extension-ide-feasibility.md` (E1) stands, with its two blockers (unsaved buffers, resident process) as the first work. +- Baseline gates on `e889f8b`, run in a clean worktree (results in §11). + +## 1. Read before planning + +- OX-01 contract: `spec/OwenExtension.md` (descriptors, capabilities, OWENB codes, manifest), `owen-extension-alpha-preregistration.md`, `owen-diagnostics-model.md` (the `Finding` model, the missing column on the msbuild line), `owen-extension-ide-feasibility.md`. +- `docs/howto-visual-studio.md`: today's VS path is the build host; Error List rows come from canonical MSBuild lines on build. +- Microsoft VSSDK samples (pattern only, nothing copied): `ErrorList` (an `ITableDataSource` registered on `StandardTables.ErrorsTable`, entries as `TableEntriesSnapshotBase` with `StandardTableKeyNames`), the `IErrorTag` tagger pattern (`ITaggerProvider` + `TagsChanged`). +- Snipper (`PhysShell/snipper` 43b395a, MIT): `extensions/snipper-vs` (SDK-style VSIX csproj with `Microsoft.VSSDK.BuildTools`, `scripts/vs-smoke.ps1` real-IDE smoke, mocked-VS tests). Reuse inventory in §9. + +## 2. What is built (architecture) + + Visual Studio (devenv, net472) + Owen.VisualStudio (VSIX, MEF; ONE project, knows no extension) + | reads /obj/owen/live.txt (written by Owen.Build: §3) + | snapshot of the Roslyn workspace: unsaved text of open documents + source-generated documents + v framed JSON over the child's stdin/stdout (owen-live/1, §4) + `dotnet exec /tools/net8.0/any/ownsharp.dll serve` (long-lived) + = the build host's own code path (descriptor validation, input set, refusals) + + InProcessExtractor: the ONE extractor program, run in-process over an overlay + -> facts (OwnIR v2 + extension facts) -> packaged Rust core `own-cli ownir --format sarif` + <- findings: the core's Finding, unchanged + +- **One frontend.** The extractor is not copied, ported or wrapped in a second implementation. `OwnSharp.Extractor` gains `InProcessExtractor.Run(args, cwd, overlay)`: the same program with the same arguments, whose one source read consults an in-memory overlay first. The CLI, `owen build-check` and `owen serve` all run that program. + - *Why not `Analyze(Compilation)`:* the VS host runs on .NET Framework inside devenv; the extractor is .NET 8 and builds its own compilation (TPA + project `bin/` references). Handing it a VS `Compilation` object across a process boundary is impossible, and building the facts from a different compilation would be a second frontend semantics. The snapshot crossing the boundary is therefore **text** (path -> contents), and the compilation is built by the one extractor exactly as on the CLI. +- **P1 kill-first result (measured before this commit, the only code written before it).** The extractor *is* separable without a semantic rewrite. Its process-global state is four statics: two were already reset at entry (`BodyThrowEdges`, `WeaverOwnedFiles`), two were not (`GuardedFactsViolations`, `OrphanedAwaitables.Sites`) and now are; its one source read (`File.ReadAllText` in the parse loop) consults the overlay. `tests/check_extractor_in_process.py`: 98 extractor jobs (every sample, every protocol case, 6 flag combinations, the OrderBackend project) run in ONE process, forward and reversed, are byte-identical to the command line's facts and exit codes; an overlay equal to the disk changes nothing; an overlay that differs is read instead of the disk and equals the command line over the same contents on disk: **198/198**. No STOP. +- **One host contract.** `owen serve` reuses `BuildCheckCommand`'s descriptor validation, input-set rule (project + the declared generators' output), refusal canonicalisation and capability list. Nothing about any extension is in the service or the VSIX. +- **One engine.** The Rust core decides; the VSIX and the service only carry its `Finding` (via the existing SARIF renderer, pinned by BR-V9) to the editor. +- **No new packaging.** The service is the `ownsharp.dll` already inside `Owen.Build` (`tools/net8.0/any/`, Rust core included). The VSIX ships no core, no extractor, no Python; it needs `dotnet` (already required by Owen.Build) and a project that references `Owen.Build` through any extension. +- Extensions never ship VS code. There is no `Owen.TypedBuilder.VisualStudio` / `Owen.Memory.VisualStudio` / `Owen.Types.VisualStudio`; a future extension gets the IDE transport by declaring a descriptor, exactly as it gets the build host. + +## 3. How the VSIX learns the active extensions (P6) without a build + +- `Owen.Build.targets` gains target `OwenLiveRequest` that writes `obj/owen/live.txt` in the **same key/value format** as the build host's `request.txt`, from the **same items**: `project`, every `descriptor` (`@(OwenExtensionDescriptor)`), `generated-root`, `severity`, plus `host` (the package's `ownsharp.dll`) and `dotnet` (`$(DOTNET_HOST_PATH)` when MSBuild knows it). +- It runs in **design-time builds** (which Visual Studio performs on project load without the user building) and in normal builds. It does nothing else: no analysis in a design-time build. +- No `live.txt` (Owen.Build not referenced, `OwenEnabled=false`): the VSIX does nothing for that project. `live.txt` present but the service rejects a descriptor (OWENB002/003/004, e.g. an unknown required capability): that OWENB diagnostic is shown live, as an Error List row on the descriptor (K7); never a silent skip. + +## 4. Process model and protocol `owen-live/1` + +- **Process.** One service process per `host` path (projects on the same Owen.Build version share it), started lazily by the VSIX on the first analysis, `DOTNET_ROLL_FORWARD=Major`, no window, stdin/stdout piped for the protocol, stderr captured into the VSIX's output pane (never discarded). Killed with devenv (Windows job object, `KILL_ON_JOB_CLOSE`) so it is never orphaned. +- **Framing.** Every message both ways is `Content-Length: \r\n\r\n` + `n` bytes of UTF-8 JSON. Nothing else is ever written to the service's stdout: the service replaces `Console.Out` with stderr at start-up and writes frames to the raw stdout stream only. +- **Handshake.** First client frame `{"type":"hello","protocol":"owen-live/1"}`; the service answers `{"type":"hello","protocol":"owen-live/1","host":"","capabilities":[...]}`. Any other protocol string, a malformed header, a non-JSON body, an unknown `type`: the service writes one `{"type":"fatal","message":...}` frame when it still can, prints the reason to stderr and exits **3**. The client treats a `fatal` frame or EOF as service death (K6). No guessing, no resynchronisation. +- **Requests.** `{"type":"analyze","id":n,"key":"","version":v,"request":"","documents":[{"path","text"}],"generated":[{"path","text"}]}` — `documents`: the open documents of the project, with their current (unsaved) text; `generated`: the project's source-generated documents with the path Roslyn gives them. `{"type":"cancel","id":n}`; `{"type":"shutdown"}` (exit 0). +- **Responses.** `{"type":"result","id":n,"key","version","status":"ok"|"superseded"|"cancelled"|"error","diagnostics":[...],"timing":{...}}`. +- **Determinism.** Same request -> same diagnostics in the same order (the core's order; host diagnostics first, in the build host's order). + +## 5. Cancellation and versioning contract (P7, K4) + +- **Client.** Each edit of a project's open document (re)starts a **250 ms** debounce for that project. When it fires, the client takes ONE workspace snapshot, assigns `version = ++counter[project]` and sends `analyze`. A response is **published only if** its `version` equals the latest version issued for that project; anything older is dropped on arrival, whatever its order of arrival. Diagnostics are always mapped onto the text snapshot the request was taken from and translated forward with tracking spans; a buffer edited after the request keeps the last published result until the newer one arrives. +- **Service.** Requests run one at a time. A queued `analyze` for a key that has a newer queued `analyze` is answered `superseded` without running; `cancel` of a queued request answers `cancelled`. A running analysis is not interrupted mid-extraction (the extractor is not cancellable) — its answer is dropped by the client rule above. +- **Stale never reappears:** a response with version N arriving after N+1 was issued is never shown (K4; mutation M1 removes the version check and K4 must go red). + +## 6. Diagnostic contract (P4) and the coordinate gap + +- **Carried per diagnostic:** `code`, `severity` (error|warning, the host's `OwenSeverity` applied by the core exactly as for the build), `message` (the core's text, byte-identical to the msbuild line's), `file` (absolute), `line`, `column` (when known), `origin` (`core` | `host`), `source` = `Owen`, `extension` (the active extension ids of the project; a finding is not attributed to one extension, because the core does not attribute it), `related` (`file`, `line`, `column?`, `message`: the core's witness/flow steps). +- **Source:** the core's `Finding` through `own-cli ownir --format sarif` (existing, pinned renderer): `ruleId`, `level`, `message.text`, `region.startLine/startColumn`, `codeFlows`/`relatedLocations`. Host conditions (OWENB) come from the build host's own records, not parsed from text. +- **Gap 1 — columns.** `Finding.column` exists, but the state-protocol lowering emits lines only (`ProtocolLowering.Line`), so OWN002 has **no column** in the facts or the finding. Adding columns to the protocol ops would change facts bytes and the core's pinned output: forbidden here (P2, P13). **Coordinate contract (narrowest):** when the core gives a column, the span starts there; when it gives none, the span is the line's text without leading/trailing whitespace, and the column shown is that span's first character (1-based). The VSIX computes it from the snapshot the request was taken from; it is a coordinate, never a verdict. The build's msbuild line has no column at all: **declared renderer difference** for the parity test. +- **Gap 2 — where OWN002 points (issue #393).** For a stale state token the core reports the finding at the **region entry** (`OrderProtocol.WithDraft(order, draft =>`), and its witness step "used here after it was released/returned" at the stale use (the second `draft.Submit(now);`). Moving the primary location is a core change (forbidden, #393 stays open). The VS host therefore shows the finding **once in the Error List at the core's location** (identical to the build's row), and draws a squiggle at the primary span **and at each witness step** of the same finding, with the step's message. The squiggle on the second `draft.Submit(now);` is the core's witness step, not a location the VSIX derived. This is the acceptance's "squiggle on the second Submit". + +## 7. Acceptance scenarios + +Fixture `tests/owen-live/LiveFixture/`: a net8.0 project with `PackageReference Owen.TypedBuilder` (local feed, as in the OX-01 gate), an `Order` declared for the generator, and `Use.cs` whose `Run` has ONE `draft.Submit(now);` in a `WithDraft` region (clean). Synthetic second extension `tests/owen-extensions/Owen.TestProtocol/` (never shipped): a descriptor + its own incremental generator that emits a different protocol (`Turnstile`) as generated source. + +| id | scenario | pass | where | +|---|---|---|---| +| K1 | unsaved edit that stays clean | no OWN row, no OWN tag; Owen ran (a result for that version arrived) | service tests + VS run | +| K2 | unsaved edit inserting a second `draft.Submit(now);` | OWN002 Error List row (file, line = region entry, column per §6), squiggle tags at the primary span and on the inserted line, no save, no build | service tests + **VS run** | +| K3 | delete the inserted line (unsaved) | the OWN002 row and every tag disappear | service tests + **VS run** | +| K4 | response N delivered after N+1 | N is never published; final state = N+1's | client unit test (scheduler/gate) | +| K5 | syntax-broken edit | no crash, no stale-looking verdict: a result arrives (findings or an OWENB010 refusal row, as the build would give for the same text); the next good edit recovers | service tests | +| K6 | the service dies | one operational Error List row `OWENV001` (Owen live analysis stopped: exit code, stderr tail); restart on the next edit, at most 3 restarts in 5 minutes, then the row stays and says live analysis is off until the solution is reopened. Never silent. | client unit test + service kill test | +| K7 | an active extension requires an unknown capability | OWENB004 row live, on the descriptor; no analysis claimed | service tests | +| K8 | two extensions (Typed Builder + Owen.TestProtocol) | both protocols' findings live, no change to the service or the VSIX | service tests (+ VS run if the second fixture is opened) | +| P9 | `CS1061` and OWN002 at once | both rows in the Error List; Owen never converts a CS diagnostic | **VS run** | +| P10 | build/live parity | for the same saved source: `dotnet build` OWN rows == live diagnostics on (code, severity, message, file, line); a live-only or build-only OWN verdict fails | `scripts/owen_live_gate.py` | + +**"Live"** means: the diagnostics reflect the text in the editor, unsaved, within the latency budget, without a save, a build or any user command. + +## 8. Latency and resources (P15) — thresholds fixed now + +Measured on the CI Windows runner in the real VS run (and on Linux for the service alone), on the small fixture: +- cold start (service spawn + hello + first analysis): report only; +- warm analysis inside the service (extractor + core): **p95 < 750 ms**; +- edit -> squiggle visible (end of debounce included): **p95 < 1 s** (over >= 10 edits); NO_GO if the warm small-project diagnostic takes several seconds; +- clean edit -> disappearance: **p95 < 1 s**; +- burst of 20 keystrokes 30 ms apart -> exactly 1-2 analyses run, the last version published; +- UI thread: no Owen handler longer than **50 ms** (stopwatch around every UI-thread callback, max reported); +- service working set after 100 analyses: **< 2x** the working set after 10 (no unbounded growth). + +## 9. Snipper reuse (P11) — ADAPT only, MIT, origin recorded in each file + +| chunk | origin | how | +|---|---|---| +| SDK-style VSIX project skeleton (`Microsoft.VSSDK.BuildTools`, pkgdef/vsix properties) | `extensions/snipper-vs/Snipper.VisualStudio/Snipper.VisualStudio.csproj` | ADAPT | +| real-IDE smoke: vswhere instance pick, deploy with `DeployVsixExtensionFiles` into a dedicated root suffix (never the shared `Exp`), restart-after-registration relaunch, activity-log evidence | `extensions/snipper-vs/scripts/vs-smoke.ps1` | ADAPT | +| test project compiling the VSIX's VS-independent sources by `` | `Snipper.VisualStudio.IntegrationTests.csproj` | ADAPT (pattern) | +| NOT reused | LSP client spine, PATH fallback binary locator, Snipper commands, its process lifecycle (stderr lost, no orphan protection: rejected in the OX-01 audit) | — | + +## 10. Real Visual Studio (P14) + +- Probe on `windows-latest` (this branch, run 37163037704): **Visual Studio Enterprise 2026, 18.10**, with the extension-development workload, `devenv` and `VSIXInstaller` present, interactive session. A DTE object is obtained, but calls were rejected during first launch: the first-run experience must be handled (UI Automation dismissal or settings preseed) — this is infrastructure work, not product. +- Supported target: VS 2022 17.10+ and VS 2026 (`InstallationTarget [17.10,19.0)`); proven on whatever the runner has (18.x). VS 2022 unproven unless a run happens. +- The VS run (`scripts/vs/owen-vs-acceptance.ps1`, workflow `owen-vs.yml`): build + deploy the VSIX into a dedicated root suffix; pack Owen.Build / Owen.TypedBuilder into a local feed; restore the fixture; launch devenv; drive it with DTE: open `Use.cs`, insert the second `draft.Submit(now);` through `EditPoint` (unsaved), poll the Error List (`ErrorItems`: description, file, line, column, project), `Navigate()` the OWN002 row and read the caret line/column, delete the line, poll until gone. The tagger's tags are observed through an opt-in trace (`OWEN_LIVE_TRACE=`: one JSON line per tag set the tagger publishes — file, snapshot version, line, column, length, code) since DTE cannot read tags. A screenshot of the squiggle is kept. +- **GO without a passing real VS run is forbidden.** If the runner cannot be driven, the verdict is NO_GO with that measured blocker. + +## 11. Gates kept green + +`tests/run_tests.py`, ruff, mypy, `cargo fmt/clippy/test`, `protocol_gate --rust`, `heap_effects_gate`, `typed_builder_gate --rust`, `owen_extension_gate` (55/55), Owen.Cli packaging, the P-022 merge gate. Plus new: `tests/check_extractor_in_process.py` (E1–E4), `scripts/owen_live_gate.py` (service + parity + K-scenarios on Linux and Windows), the VS run. + +Baseline on `e889f8b` (clean worktree): filled in by Amendment 1 when the runs finish; a pre-existing red is recorded, not fixed. + +## 12. Mutation controls + +| id | mutation | must turn red | +|---|---|---| +| M1 | the client ignores `version` (publishes every response) | K4 | +| M2 | the service cannot start (bad host path) | the OWENV001 row appears (K6 test) | +| M3 | wrong line/column mapping (off by one) | navigation/coordinate test (VS run and the client coordinate unit test) | +| M4 | the service routes generated files only for `Owen.TypedBuilder.Generator` | K8 (Owen.TestProtocol's finding missing) | +| M5 | live renders with a different verdict path (e.g. severity forced, or msbuild text parsed with a different message) | P10 parity | +| M6 | the unsaved document is read from disk | K2 (service test) and E4 | + +## 13. Out of scope + +VS Code, an LSP server, Rider/ReSharper, code fixes/lightbulbs, hover/explain UI, the Memory/TypeDisciplines extensions, a second aggregate in Typed Builder, BCL summaries, typed write targets, whole-solution incremental analysis (the unit is one project), Marketplace publishing, analysis of a brand-new file that was never saved (the input set is the project's files on disk; their contents come from the editor), columns for state-protocol findings (#393 / the facts), live analysis before the first design-time build of a project. + +## 14. Verdict + +`GO_OWEN_VISUAL_STUDIO_ALPHA` only if all hold: an unsaved edit goes through the one extractor and the authoritative core to OWN002, a squiggle, an Error List row with navigation to the reported location, and disappears when the line is deleted — in a real Visual Studio; the parity gate passes; K1–K8 pass where registered; thresholds of §8 hold; mutations M1–M6 go red. Otherwise `NO_GO_OWEN_VISUAL_STUDIO_ALPHA` with the exact measured blocker. After GO: STOP. + +## Amendment 1 (2026-10-04): baseline results, and what the real IDE required + +**Baseline on `e889f8b`** (clean worktree, Linux): `cargo test` all ok (51 result lines); `protocol_gate --rust` 0 failures; `heap_effects_gate` PASS; `typed_builder_gate --rust` 0 failures (transcript `ba447304…`); `owen_extension_gate` 55/55; `tests/run_tests.py` exit 1 with exactly the two pre-existing local-only Stage-1 controls (`divergence-is-5`, `exec-failure-is-5`; red only when the launcher is built locally, recorded in OX-01) — CI on the same base is green. P-022 merge-gate controls 15/15 + wiring 12/12. + +**Changes to the plan, none to a threshold or a verdict rule:** +1. **The Error List is read with UI Automation, not `DTE.ToolWindows.ErrorList.ErrorItems`.** In Visual Studio 2026 18.10 `ErrorItems` returned 0 while the window showed CS0029 (probe run 37163671022, screenshot). UI Automation reads the rows the user sees (code, description, project, file, line); the column is checked through navigation. +2. **The hive.** CI runs in the image's `Exp` hive (an ephemeral runner; nothing shared to protect) instead of a dedicated root suffix: a fresh suffix opens on the modal sign-in dialog, which rejects every DTE call. The driver also dismisses first-run dialogs through UI Automation. +3. **The MEF cache.** The image ships the hive's `ComponentModelCache` prebuilt, and installing a VSIX rebuilt only the pkgdef cache, so Owen's parts were never composed (identical cache bytes across runs, Owen absent from the composition log). The driver drops that cache and runs `devenv /updateconfiguration` after installing, as a developer machine would on a first start after an install. +4. **"Loaded" is the extension's own trace line** (`Owen live analysis loaded`), not `Process.Modules`, which does not reliably list a .NET Framework assembly. +5. **The editor must be in front.** VS reopens its start page over the editor after load; a view that is not visible is never asked for tags, so the driver brings `Use.cs` to the front before each measured edit. (Product change made while finding this: the tagger raises `TagsChanged` on the UI thread and reports any exception instead of being switched off silently.) +6. **E3/E4** overlay a self-contained input (`frontend/roslyn/samples`): OrderBackend's extraction refuses on a clean checkout (no `bin/`, EF types unresolved), so it compared no facts with no facts in CI. +7. **P-037 pins** the extractor's disk read sites in `Program.cs`; the overlay is consulted before them (`TryOverlay`, no filesystem access) instead of replacing them. diff --git a/docs/notes/owen-visual-studio-report.md b/docs/notes/owen-visual-studio-report.md new file mode 100644 index 00000000..2cdeae26 --- /dev/null +++ b/docs/notes/owen-visual-studio-report.md @@ -0,0 +1,151 @@ +# OX-02 report: one generic `Owen.VisualStudio` host + +Preregistration: `owen-visual-studio-preregistration.md` (committed before product code, plus +Amendment 1). Base `main` = `e889f8b` (OX-01). Verdict at the end. + +## 1. What was built + +| piece | where | what it is | +|---|---|---| +| extractor as a library | `OwnSharp.Extractor/InProcess.cs` (+ 2 resets and the overlay look-up in `Program.cs`) | `InProcessExtractor.Run(args, cwd, overlay)`: the ONE extractor program run inside a long-lived process; an unsaved buffer is read instead of its file | +| shared host contract | `OwnSharp.Cli/OwenHost.cs` | descriptor validation, input set, refusals, manifest: one implementation for `build-check` (byte-identical output) and `serve` | +| the live service | `OwnSharp.Cli/ServeCommand.cs`, `LiveAnalysis.cs` | `owen serve`, protocol `owen-live/1` (framed, versioned, fatal on malformed input, newest-per-project wins); findings are the Rust core's, via its SARIF renderer | +| the live request | `Owen.Build/build/Owen.Build.targets` (`OwenLiveRequest`) | `obj/owen/live.txt` from the SAME request lines as the build, written by design-time builds too | +| the VS host | `frontend/roslyn/Owen.VisualStudio` (VSIX, one project, knows no extension) | workspace snapshots (unsaved open documents + Roslyn's in-memory generated documents) → supervised service → version gate → `IErrorTag` squiggles + `ITableDataSource` Error List rows | +| synthetic extension #2 | `tests/owen-extensions/Owen.TestProtocol` | its own generator and protocol (a turnstile): K8 and M4 | +| gates | `tests/check_extractor_in_process.py`, `scripts/owen_live_gate.py`, `tests/owen-live/LiveClientTests`, `scripts/owen_live_mutations.py`, `scripts/vs/*` | see §3 | + +No change to OwnIR, H0/H1, the state protocol, T0/P-022, diagnostic meanings or OX-01 +capability semantics. The Rust core is untouched. + +## 2. Two coordinate facts (preregistration §6) + +- **No column for state-protocol findings.** The protocol lowering emits lines only, so OWN002 carries none. The VS host applies the registered contract: the span is the reported line without surrounding whitespace, and the column shown is its first character. In VS the Error List row navigates to `Use.cs` 10:9, exactly that. +- **Where OWN002 points (#393, unchanged).** The core reports a stale state token at the region entry (`OrderProtocol.WithDraft(order, draft =>`, line 10). Its witness step "used here after it was released/returned" is the stale use (the inserted `draft.Submit(now);`, line 13). The Error List shows the finding once, at the core's location, as the build does. The squiggle is drawn at both: the one on the inserted line is the core's witness step, not a location the VSIX derived. + +## 3. Evidence + +### 3.1 Extractor as a library (P1/P2) — `tests/check_extractor_in_process.py`, 198/198 +- 98 extractor jobs: every sample, every protocol case, 6 flag sets, OrderBackend. +- Run in ONE process, forward and reversed: facts and exit codes are byte-identical to the command line's (E1/E2). +- An overlay equal to the disk changes nothing (E3). +- An overlay that differs is read instead of the disk and equals the command line over the same contents on disk (E4). +- CI `owen-live`: green on Ubuntu and Windows. + +### 3.2 The service (Linux and Windows CI) — `scripts/owen_live_gate.py`, 27/27 +- **L0:** a design-time build writes `live.txt` naming the package's service. No `bin/`, no build request, no manifest: nothing analysed. Generated sources stay in memory only. +- **S:** + - stdout carries frames only; + - a bad protocol, a bad header, a non-JSON body or an unknown type each gives a `fatal` frame and exit 3; + - clean shutdown exits 0. +- **Scenarios:** + - K1 clean, unsaved; + - K2 OWN002 at the region entry with a witness on the inserted line, disk still clean; + - K3 gone; + - K5 broken syntax answered, next edit recovers; + - K4s queued 6/7/8 → `ok, superseded, ok`, cancel → `cancelled`; + - K7 an unknown capability gives OWENB004 live; + - K8 Typed Builder + Owen.TestProtocol, both protocols' OWN002, no host change. +- **P10 parity:** build lines == live diagnostics on (file, line, severity, code, message) for clean / stale (OWN002) / copy (OWN005) / raw entity (OWN013) / refused (OWENB010). +- **T (Linux):** + - warm round trip p50 105 ms, p95 208 ms (threshold 750); in the service extract ≈ 88 ms and core ≈ 2 ms; + - cold start 1.7 s; + - working set 432 MB after 10 analyses, 455 MB after 100 (threshold 2×). +- **C (client half, against a real `owen serve`), 13/13:** + - coordinates; + - one Error List row plus a witness squiggle; + - K4 (a late answer is never published; M1 control); + - bursts coalesce (20 edits 30 ms apart → 1 analysis); + - K6 (a crash is shown, the next request restarts, after 3 restarts in the window it stays off and says so); + - M2 control. +- **G:** neither the service nor the VSIX names an extension. + +### 3.3 Mutations (M1–M6) — `scripts/owen_live_mutations.py`, 6/6 caught +| | mutation | caught by | +|---|---|---| +| M1 | publish every answer | K4-stale-dropped | +| M2 | swallow a service that cannot start | M2-cannot-start | +| M3 | coordinates off by one | coord-no-column, coord-column | +| M4 | route only Typed Builder's generator | K8-two-extensions | +| M5 | live forms its own severity | P10-parity-stale/copy/raw | +| M6 | read the unsaved document from disk | K2-unsaved-own002 (and E4) | + +### 3.4 Real Visual Studio (P14) — CI job `owen-visual-studio`, Visual Studio Enterprise 2026 18.10 on `windows-2025-vs2026` +**19/19 on the final product head `fbdc978`** (run 37175674769; evidence kept in +`evidence/ox02-visual-studio/`). The solution is restored and never built. The VSIX is +installed with `VSIXInstaller` into the image's `Exp` hive. The IDE is driven as a user would +drive it: +- edits go through DTE `EditPoint`, never saved; +- the Error List is read through UI Automation; +- navigation is a double-click on the row; +- squiggles come from Owen's own tagger trace and are visible in the screenshots. + +| check | result | +|---|---| +| vsix-loaded | Owen.VisualStudio composed in devenv (its trace: `Owen live analysis loaded`) | +| L0 | Visual Studio's own design-time build wrote `obj/owen/live.txt`; no build ran | +| K1 | first publication: 0 entries; an unsaved clean edit: analysed, 0 OWN entries | +| **K2 squiggle** | `10:9+39 'OrderProtocol.WithDraft(order, draft =>'` (finding) and `13:13+18 'draft.Submit(now);'` (witness, the inserted line), `Use.cs` unsaved | +| **K2 Error List** | `OWN002 \| IDisposable local 'draft' is used after it is disposed [resource: disposable] \| LiveFixture \| Use.cs \| 10` | +| **K2 navigation** | double-click → `Use.cs` line 10 column 9 = the registered coordinate | +| P9 | `CS0029 … Use.cs 10` beside the OWN002 row | +| **K3** | line deleted → no OWN002 tag after 344 ms; the row is gone | +| edit → squiggle | 10/10, p95 **349 ms** (threshold 1000); 328–349 ms with the 250 ms debounce included | +| edit → disappearance | 10/10, p95 **347 ms** (threshold 1000) | +| UI thread | longest Owen handler **3.3 ms** (threshold 50) | +| service in VS | 26 analyses; cold (first) 4.9 s; warm median **58 ms**, p95 76 ms, max 150 ms; working set 169 → 203 MB | +| no-build / never-saved | no `LiveFixture.dll`, no build request; `Use.cs` on disk byte-identical to the fixture | +| no-orphan | `owen serve` did not outlive a killed devenv (job object) | + +The same product code also passed 19/19 on `90bd419` (run 37174835723: p95 399/413 ms, UI +4.8 ms). Run 37174809967 on `7cdf529` (same product code) failed one check, +K2-navigation, with the caret at exactly 10:9 = expected. The driver's path comparison is +now normalised and prints both paths; it passed on `fbdc978`. Not counted as a pass. + +**What the IDE run took** (Amendment 1): the Error List read through UI Automation (DTE +`ErrorItems` returned 0 in VS 2026), the `Exp` hive plus first-run dialog dismissal, a MEF +cache rebuilt after install, and the editor kept in front. One product fix came out of it: +`TagsChanged` now raises on the UI thread and the tagger reports its exceptions. + +## 4. Gates kept green +Final head, CI run 37175674769: **every job green**, Ubuntu and Windows. That includes: +- lint (ruff + mypy --strict); +- tests on Python 3.11/3.12/3.13 (`run_tests.py`, P-037 pins included); +- rust fmt/clippy/tests; +- the state-protocol gate, the heap-effects gate and the typed-builder gate; +- `owen_extension_gate` (55/55 on both platforms); +- Owen.Cli gate A on both platforms, and the Stage-1 engine controls; +- Rust-default dogfood; +- the new `owen-live` job (Ubuntu + Windows) and `owen-visual-studio`. + +Locally `run_tests.py` shows the same two pre-existing local-only Stage-1 controls as on the +base (Amendment 1). + +## 5. Snipper reuse +`owen-visual-studio-snipper-reuse.md`: four ADAPTed chunks (VSIX project shape, manifest, the +real-IDE smoke's instance/hive/relaunch shape, link-compiled tests). Nothing copied verbatim; +the LSP spine, PATH fallback, commands and lifecycle are not reused. + +## 6. Verdict +**GO_OWEN_VISUAL_STUDIO_ALPHA.** + +In a real Visual Studio 2026, an unsaved edit goes through the shared Roslyn frontend (the ONE +extractor, in-process, over the editor's text and Roslyn's in-memory generated sources) to the +authoritative Rust core. The result is OWN002: a live squiggle on the inserted +`draft.Submit(now);` (the core's witness step) and at the finding's location, and an Error List +row with exact navigation to the reported location. Deleting the line removes them. No save, +no build. + +For the same saved source, build and live agree on code, severity, message, file and line +(P10, five sources). K1–K8, M1–M6 and the §8 thresholds hold. The VS host and the service +name no extension; Owen.TestProtocol, a second extension with its own generator, works with +no host change. + +Known and registered: +- OWN002 points at the region entry, with the stale use as its witness step (#393, unchanged); +- state-protocol findings have no column (the narrowest coordinate contract applies); +- a never-saved new file is not in the input set; +- live analysis starts after the project's first design-time build; +- proven on VS 2026 18.10 only; VS 2022 17.14 is declared by the manifest but was not run. + +STOP here, per the brief. diff --git a/docs/notes/owen-visual-studio-snipper-reuse.md b/docs/notes/owen-visual-studio-snipper-reuse.md new file mode 100644 index 00000000..2307b19f --- /dev/null +++ b/docs/notes/owen-visual-studio-snipper-reuse.md @@ -0,0 +1,19 @@ +# OX-02 P11: what Owen.VisualStudio reuses from Snipper + +Source: `PhysShell/snipper` at `43b395a`, MIT licence (its `LICENSE`). Only audited +infrastructure was taken, and only by **adaptation** (nothing is vendored or copied verbatim). +The OX-01 audit (`owen-extension-snipper-salvage.md`) rejected Snipper's long-lived process +lifecycle (stderr lost, crashes unwatched, orphans possible); none of it is used here. + +| # | chunk in Owen.NET | origin in Snipper | verdict | what changed | +|---|---|---|---|---| +| 1 | `frontend/roslyn/Owen.VisualStudio/Owen.VisualStudio.csproj` (SDK-style VSIX project: `net472`, `Microsoft.VisualStudio.SDK` with `ExcludeAssets="runtime"`, `Microsoft.VSSDK.BuildTools`, `CreateVsixContainer`) | `extensions/snipper-vs/Snipper.VisualStudio/Snipper.VisualStudio.csproj` | ADAPT | no cargo build or bundled binary (the service comes from the project's `Owen.Build`); no pkgdef/VsPackage (MEF only); the build tools only on Windows so the project compiles on Linux | +| 2 | `frontend/roslyn/Owen.VisualStudio/source.extension.vsixmanifest` | `extensions/snipper-vs/Snipper.VisualStudio/source.extension.vsixmanifest` | ADAPT | MEF component only; targets 17.14–18.x; adds the Roslyn language-services prerequisite | +| 3 | `scripts/vs/owen-vs-acceptance.ps1`: vswhere instance selection, a dedicated root suffix (never the shared `Exp`), the `/log` activity log, relaunch after the registration restart | `extensions/snipper-vs/scripts/vs-smoke.ps1` | ADAPT | installs with `VSIXInstaller` instead of `DeployVsixExtensionFiles`; drives the IDE (DTE edits, UI Automation Error List, Owen's trace) instead of only checking that a process started | +| 4 | `tests/owen-live/LiveClientTests/LiveClientTests.csproj`: the VS-independent sources compiled into a test project by `` | `extensions/snipper-vs/Snipper.VisualStudio.IntegrationTests/*.csproj` | ADAPT (pattern) | `net8.0` console runner on any OS instead of a `net472` mocked-VS xunit project; tests the real `owen serve` | + +**Not reused**, by decision: the LSP client spine (`SnipperLanguageServerProvider`, StreamJsonRpc), +the PATH-fallback binary locator, Snipper's commands/options page, its process lifecycle. +`owen-live/1` is a small purpose-built framing (`Content-Length` + JSON, fatal on anything +malformed); the service lifecycle is new (`LiveService`/`LiveSupervisor`: stderr kept, death +detected and shown, restart budget, a Windows job object so the child never outlives devenv). diff --git a/frontend/roslyn/Owen.Build/build/Owen.Build.targets b/frontend/roslyn/Owen.Build/build/Owen.Build.targets index d7b3638c..2304892d 100644 --- a/frontend/roslyn/Owen.Build/build/Owen.Build.targets +++ b/frontend/roslyn/Owen.Build/build/Owen.Build.targets @@ -18,22 +18,51 @@ <_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 Remove="@(_OwenRequestLine)" /> <_OwenRequestLine Include="project $(MSBuildProjectFullPath)" /> <_OwenRequestLine Include="@(OwenExtensionDescriptor->'descriptor %(FullPath)')" /> <_OwenRequestLine Include="generated-root $(_OwenGeneratedRoot)" Condition="'$(_OwenGeneratedRoot)' != ''" /> <_OwenRequestLine Include="severity $(OwenSeverity)" /> + + + + + + + <_OwenLiveLine Include="@(_OwenRequestLine)" /> + <_OwenLiveLine Include="host $(_OwenHostDir)ownsharp.dll" /> + <_OwenLiveLine Include="dotnet $(DOTNET_HOST_PATH)" Condition="'$(DOTNET_HOST_PATH)' != ''" /> + + + + + + + + <_OwenRequest>$(_OwenObjDir)request.txt + + <_OwenRequestLine Include="manifest $(_OwenObjDir)extensions.json" /> <_OwenRequestLine Include="emit-facts $(OwenEmitFacts)" Condition="'$(OwenEmitFacts)' != ''" /> diff --git a/frontend/roslyn/Owen.VisualStudio/Live/ChildProcessGuard.cs b/frontend/roslyn/Owen.VisualStudio/Live/ChildProcessGuard.cs new file mode 100644 index 00000000..e9c1e16a --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/Live/ChildProcessGuard.cs @@ -0,0 +1,92 @@ +// OX-02 (preregistration §4): the service never outlives Visual Studio. On Windows the child is +// put in a job object that kills it when the job's last handle — held by this process — closes, +// so a crashed or killed devenv cannot leave an orphan. Elsewhere (the tests) the parent kills +// it on dispose. +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace Owen.VisualStudio.Live +{ + internal static class ChildProcessGuard + { + private static IntPtr _job = IntPtr.Zero; + private static readonly object Gate = new object(); + + public static void Attach(Process process) + { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + return; + lock (Gate) + { + if (_job == IntPtr.Zero) + { + var job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) + return; + var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + info.BasicLimitInformation.LimitFlags = 0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + var size = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION)); + var ptr = Marshal.AllocHGlobal(size); + try + { + Marshal.StructureToPtr(info, ptr, false); + if (!SetInformationJobObject(job, 9 /* JobObjectExtendedLimitInformation */, ptr, (uint)size)) + return; + } + finally + { + Marshal.FreeHGlobal(ptr); + } + _job = job; + } + AssignProcessToJobObject(_job, process.Handle); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IO_COUNTERS + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr CreateJobObject(IntPtr attributes, string? name); + + [DllImport("kernel32.dll")] + private static extern bool SetInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint length); + + [DllImport("kernel32.dll")] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + } +} diff --git a/frontend/roslyn/Owen.VisualStudio/Live/LiveEngine.cs b/frontend/roslyn/Owen.VisualStudio/Live/LiveEngine.cs new file mode 100644 index 00000000..d95072ed --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/Live/LiveEngine.cs @@ -0,0 +1,285 @@ +// OX-02: the editor-independent heart of Owen.VisualStudio. An edit of a project schedules one +// analysis of a snapshot of it; the answer becomes entries (Error List rows and squiggle spans) +// only if it is still the newest. Knows no Owen extension: what is analysed is whatever the +// project's live request (obj/owen/live.txt, written by Owen.Build) names. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Owen.VisualStudio.Live +{ + /// What an analysis is taken over: one project, as the editor has it right now. + public sealed class LiveSnapshot + { + public LiveSnapshot(string key, string request, IReadOnlyList documents, + IReadOnlyList generated, object? state = null) + { + Key = key; + Request = request; + Documents = documents; + Generated = generated; + State = state; + } + + /// The project file: one analysis unit. + public string Key { get; } + + /// The project's obj/owen/live.txt. + public string Request { get; } + + public IReadOnlyList Documents { get; } + + public IReadOnlyList Generated { get; } + + /// The editor's own handle on this snapshot (VS: text snapshots, for mapping). + public object? State { get; } + + /// The text of as analysed: the editor's, else the disk's. + public string? TextOf(string file) + { + foreach (var d in Documents) + if (string.Equals(Path.GetFullPath(d.Path), Path.GetFullPath(file), StringComparison.OrdinalIgnoreCase)) + return d.Text; + try + { + return File.Exists(file) ? File.ReadAllText(file) : null; + } + catch (IOException) + { + return null; + } + } + } + + /// One thing to show: an Error List row () or a squiggle only. + public sealed class LiveEntry + { + public string Code { get; set; } = ""; + + public string Severity { get; set; } = ""; + + public string Message { get; set; } = ""; + + public string File { get; set; } = ""; + + /// 1-based; null for a file-level condition. + public int? Line { get; set; } + + /// 1-based column shown (the span's first character, preregistration §6). + public int? Column { get; set; } + + /// 0-based [start, end) within the line, for the squiggle. + public int Start { get; set; } + + public int End { get; set; } + + /// True: the finding's own location (an Error List row and a squiggle). False: one + /// of its witness steps (a squiggle carrying the step's message). + public bool Primary { get; set; } + + public string Origin { get; set; } = ""; + + public string Project { get; set; } = ""; + } + + public sealed class LivePublication + { + public LivePublication(string key, long version, IReadOnlyList entries, LiveSnapshot? snapshot, + IReadOnlyDictionary timing) + { + Key = key; + Version = version; + Entries = entries; + Snapshot = snapshot; + Timing = timing; + } + + public string Key { get; } + + public long Version { get; } + + public IReadOnlyList Entries { get; } + + public LiveSnapshot? Snapshot { get; } + + public IReadOnlyDictionary Timing { get; } + } + + /// What answers an analysis: the supervised service, or a test double. + public interface ILiveAnalyzer + { + Task AnalyzeAsync(string key, long version, string request, + IReadOnlyList documents, IReadOnlyList generated); + + /// The operational condition to show, or null while healthy. + string? Condition { get; } + } + + public sealed class SupervisedAnalyzer : ILiveAnalyzer + { + private readonly LiveSupervisor _supervisor; + + public SupervisedAnalyzer(LiveSupervisor supervisor) + { + _supervisor = supervisor; + } + + public string? Condition => _supervisor.Condition; + + public Task AnalyzeAsync(string key, long version, string request, + IReadOnlyList documents, IReadOnlyList generated) => + _supervisor.AnalyzeAsync(key, version, request, documents, generated); + } + + public sealed class LiveEngine + { + /// The code of Owen.VisualStudio's own operational row (the service stopped). + public const string ServiceCode = "OWENV001"; + + private readonly Func> _snapshot; + private readonly Func _analyzer; + private readonly Action _log; + private readonly LiveDebouncer _debouncer; + private readonly LiveVersionGate _gate = new LiveVersionGate(); + + /// Mutation M1 (preregistration §12): publish every answer, current or not. + public bool IgnoreVersions { get; set; } + + public LiveEngine(Func> snapshot, Func analyzer, + Action log, TimeSpan debounce) + { + _snapshot = snapshot; + _analyzer = analyzer; + _log = log; + _debouncer = new LiveDebouncer(debounce); + } + + public event Action? Published; + + /// An edit (or an open) of something in project . + public void OnEdit(string key) => _debouncer.Schedule(key, () => AnalyzeNowAsync(key)); + + /// Take the snapshot and analyse it now (the debounce's trailing edge). + public async Task AnalyzeNowAsync(string key) + { + LiveSnapshot? snapshot; + try + { + snapshot = await _snapshot(key).ConfigureAwait(false); + } + catch (Exception ex) + { + _log("owen: snapshot of " + key + " failed: " + ex); + return; + } + if (snapshot == null) + { + // no live request: Owen is not active in this project; show nothing for it + Publish(new LivePublication(key, _gate.Issue(key), Array.Empty(), null, new Dictionary())); + return; + } + var version = _gate.Issue(key); + await RunAsync(snapshot, version).ConfigureAwait(false); + } + + /// Analyse a snapshot under an already-issued version (tests drive this directly). + public long Issue(string key) => _gate.Issue(key); + + public async Task RunAsync(LiveSnapshot snapshot, long version) + { + var analyzer = _analyzer(snapshot); + if (analyzer == null) + return; + LiveResponse response; + try + { + response = await analyzer.AnalyzeAsync(snapshot.Key, version, snapshot.Request, + snapshot.Documents, snapshot.Generated).ConfigureAwait(false); + } + catch (LiveServiceDiedException ex) + { + _log("owen: " + ex.Message); + if (IgnoreVersions || _gate.IsCurrent(snapshot.Key, version)) + Publish(new LivePublication(snapshot.Key, version, new[] { Operational(snapshot.Key, ex.Message) }, + snapshot, new Dictionary())); + return; + } + if (response.Status != "ok" && response.Status != "error") + return; // superseded/cancelled: a newer request is on its way + if (!IgnoreVersions && !_gate.IsCurrent(snapshot.Key, response.Version)) + { + _log("owen: dropped the answer for version " + response.Version + " of " + snapshot.Key + " (stale)"); + return; + } + var entries = Entries(response, snapshot); + if (analyzer.Condition is string condition) + entries.Insert(0, Operational(snapshot.Key, condition)); + Publish(new LivePublication(snapshot.Key, response.Version, entries, snapshot, response.Timing)); + } + + private void Publish(LivePublication publication) => Published?.Invoke(publication); + + public static LiveEntry Operational(string key, string message) => new LiveEntry + { + Code = ServiceCode, + Severity = "warning", + Message = "Owen live analysis: " + message, + File = key, + Primary = true, + Origin = "host", + Project = Path.GetFileNameWithoutExtension(key), + }; + + /// The service's diagnostics as entries, with spans per the coordinate contract. + public static List Entries(LiveResponse response, LiveSnapshot snapshot) + { + var project = Path.GetFileNameWithoutExtension(snapshot.Key); + var texts = new Dictionary(StringComparer.OrdinalIgnoreCase); + string[]? Lines(string file) + { + if (!texts.TryGetValue(file, out var lines)) + texts[file] = lines = snapshot.TextOf(file)?.Replace("\r\n", "\n").Split('\n'); + return lines; + } + LiveEntry Entry(LiveDiagnostic d, string file, int? line, int? column, string message, bool primary) + { + var entry = new LiveEntry + { + Code = d.Code, + Severity = d.Severity, + Message = message, + File = file, + Line = line is int l && l >= 1 ? l : (int?)null, + Column = column, + Primary = primary, + Origin = d.Origin, + Project = project, + }; + if (entry.Line is int n && Lines(file) is string[] all && n <= all.Length) + { + var (start, end, shown) = LiveCoordinates.Span(all[n - 1], column); + entry.Start = start; + entry.End = end; + entry.Column = shown; + } + return entry; + } + + var entries = new List(); + foreach (var d in response.Diagnostics) + { + entries.Add(Entry(d, d.File, d.Line, d.Column, d.Message, primary: true)); + foreach (var r in d.Related) + { + if (string.Equals(r.File, d.File, StringComparison.OrdinalIgnoreCase) && r.Line == d.Line) + continue; // the finding's own line already carries the squiggle + var step = r.Message is string m && m.Length > 0 ? m + " — " + d.Message : d.Message; + entries.Add(Entry(d, r.File, r.Line, r.Column, step, primary: false)); + } + } + return entries; + } + } +} diff --git a/frontend/roslyn/Owen.VisualStudio/Live/LiveProtocol.cs b/frontend/roslyn/Owen.VisualStudio/Live/LiveProtocol.cs new file mode 100644 index 00000000..73c6794a --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/Live/LiveProtocol.cs @@ -0,0 +1,252 @@ +// OX-02 (docs/notes/owen-visual-studio-preregistration.md §4): the client half of owen-live/1. +// Independent of Visual Studio, so tests/owen-live/LiveClientTests runs it on any OS. +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Owen.VisualStudio.Live +{ + /// One source the editor holds: full path and current text. + public sealed class LiveSource + { + public LiveSource(string path, string text) + { + Path = path; + Text = text; + } + + public string Path { get; } + + public string Text { get; } + } + + /// A location the core names: the finding's own, or one of its witness steps. + public sealed class LiveLocation + { + public string File { get; set; } = ""; + + public int? Line { get; set; } + + public int? Column { get; set; } + + public string? Message { get; set; } + } + + /// One diagnostic, as the service sends it (preregistration §6). + public sealed class LiveDiagnostic + { + public string Code { get; set; } = ""; + + public string Severity { get; set; } = ""; + + public string Message { get; set; } = ""; + + public string File { get; set; } = ""; + + public int? Line { get; set; } + + public int? Column { get; set; } + + /// core (an OWN finding) or host (an OWENB condition). + public string Origin { get; set; } = ""; + + public List Related { get; set; } = new List(); + } + + public sealed class LiveResponse + { + public long Id { get; set; } + + public string Key { get; set; } = ""; + + public long Version { get; set; } + + /// ok, superseded, cancelled or error. + public string Status { get; set; } = ""; + + public List Diagnostics { get; set; } = new List(); + + public List Extensions { get; set; } = new List(); + + public Dictionary Timing { get; set; } = new Dictionary(); + } + + public sealed class LiveProtocolException : Exception + { + public LiveProtocolException(string message) + : base(message) + { + } + } + + /// Content-Length: n\r\n\r\n + n bytes of UTF-8 JSON, both ways. + public static class LiveFrames + { + public const string Protocol = "owen-live/1"; + + public static void Write(Stream output, JObject message) + { + var body = Encoding.UTF8.GetBytes(message.ToString(Formatting.None)); + var header = Encoding.ASCII.GetBytes("Content-Length: " + body.Length + "\r\n\r\n"); + output.Write(header, 0, header.Length); + output.Write(body, 0, body.Length); + output.Flush(); + } + + /// One frame, or null at a clean end of stream between frames. + public static JObject? Read(Stream input) + { + int? length = null; + var first = true; + while (true) + { + var line = ReadLine(input, first); + first = false; + if (line == null) + return null; + if (line.Length == 0) + break; + const string prefix = "Content-Length: "; + if (!line.StartsWith(prefix, StringComparison.Ordinal) + || !int.TryParse(line.Substring(prefix.Length), System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out var n)) + throw new LiveProtocolException("malformed frame header '" + line + "'"); + length = n; + } + if (length == null) + throw new LiveProtocolException("a frame without Content-Length"); + var body = new byte[length.Value]; + var read = 0; + while (read < body.Length) + { + var got = input.Read(body, read, body.Length - read); + if (got == 0) + throw new LiveProtocolException("the stream closed inside a frame body"); + read += got; + } + try + { + return JObject.Parse(Encoding.UTF8.GetString(body)); + } + catch (JsonException ex) + { + throw new LiveProtocolException("a frame body that is not a JSON object: " + ex.Message); + } + } + + private static string? ReadLine(Stream input, bool atFrameStart) + { + var bytes = new List(); + while (true) + { + var b = input.ReadByte(); + if (b < 0) + { + if (bytes.Count == 0 && atFrameStart) + return null; + throw new LiveProtocolException("the stream closed inside a frame header"); + } + if (b == '\n') + { + if (bytes.Count == 0 || bytes[bytes.Count - 1] != '\r') + throw new LiveProtocolException("a frame header line not ended by CRLF"); + bytes.RemoveAt(bytes.Count - 1); + return Encoding.ASCII.GetString(bytes.ToArray()); + } + if (bytes.Count > 1024) + throw new LiveProtocolException("a frame header line longer than 1024 bytes"); + bytes.Add((byte)b); + } + } + + public static JObject Hello() => new JObject { ["type"] = "hello", ["protocol"] = Protocol }; + + public static JObject Analyze(long id, string key, long version, string request, + IEnumerable documents, IEnumerable generated) + { + JArray Sources(IEnumerable sources) + { + var array = new JArray(); + foreach (var s in sources) + array.Add(new JObject { ["path"] = s.Path, ["text"] = s.Text }); + return array; + } + return new JObject + { + ["type"] = "analyze", + ["id"] = id, + ["key"] = key, + ["version"] = version, + ["request"] = request, + ["documents"] = Sources(documents), + ["generated"] = Sources(generated), + }; + } + + public static LiveResponse Response(JObject frame) + { + var response = frame.ToObject(JsonSerializer.Create(new JsonSerializerSettings + { + ContractResolver = new Newtonsoft.Json.Serialization.DefaultContractResolver + { + NamingStrategy = new Newtonsoft.Json.Serialization.SnakeCaseNamingStrategy(), + }, + MissingMemberHandling = MissingMemberHandling.Ignore, + })); + return response ?? throw new LiveProtocolException("an empty result"); + } + } + + /// The project's obj/owen/live.txt, written by Owen.Build (preregistration §3). + public sealed class LiveRequestFile + { + private LiveRequestFile(string path, string host, string? dotnet, string? project) + { + Path = path; + Host = host; + Dotnet = dotnet; + Project = project; + } + + public string Path { get; } + + /// The Owen.Build package's ownsharp.dll: the service. + public string Host { get; } + + /// The muxer MSBuild ran with, when it said. + public string? Dotnet { get; } + + public string? Project { get; } + + /// Where a project's live request is: obj/owen/live.txt beside it. + public static string For(string projectFile) => + System.IO.Path.Combine(System.IO.Path.GetDirectoryName(projectFile) ?? "", "obj", "owen", "live.txt"); + + public static LiveRequestFile? TryRead(string path) + { + if (!File.Exists(path)) + return null; + string? host = null, dotnet = null, project = null; + foreach (var raw in File.ReadAllLines(path)) + { + var tab = raw.IndexOf('\t'); + if (tab <= 0) + continue; + var key = raw.Substring(0, tab); + var value = raw.Substring(tab + 1).Trim(); + if (value.Length == 0) + continue; + if (key == "host") + host = value; + else if (key == "dotnet") + dotnet = value; + else if (key == "project") + project = value; + } + return host == null ? null : new LiveRequestFile(path, host, dotnet, project); + } + } +} diff --git a/frontend/roslyn/Owen.VisualStudio/Live/LiveScheduler.cs b/frontend/roslyn/Owen.VisualStudio/Live/LiveScheduler.cs new file mode 100644 index 00000000..f91eeeab --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/Live/LiveScheduler.cs @@ -0,0 +1,103 @@ +// OX-02 (preregistration §5, K4): edit -> debounce -> one snapshot -> analyze -> publish only if +// that version is still the newest one issued. Independent of Visual Studio. +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Owen.VisualStudio.Live +{ + /// + /// Per-project request versions. A result is published only when its version is the latest + /// one issued for its project, so an answer that arrives late — however late, in whatever + /// order — can never replace a newer one (K4; mutation M1 drops this check). + /// + public sealed class LiveVersionGate + { + private readonly Dictionary _issued = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public long Issue(string key) + { + lock (_issued) + { + _issued.TryGetValue(key, out var last); + _issued[key] = last + 1; + return last + 1; + } + } + + public bool IsCurrent(string key, long version) + { + lock (_issued) + return _issued.TryGetValue(key, out var last) && last == version; + } + } + + /// + /// Trailing-edge debounce per project: every edit restarts the delay; when it expires the + /// work runs once, for the text as it is then. Bursts coalesce into one analysis. + /// + public sealed class LiveDebouncer + { + private readonly TimeSpan _delay; + private readonly Dictionary _timers = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public LiveDebouncer(TimeSpan delay) + { + _delay = delay; + } + + public void Schedule(string key, Func work) + { + CancellationTokenSource cts; + lock (_timers) + { + if (_timers.TryGetValue(key, out var previous)) + previous.Cancel(); + _timers[key] = cts = new CancellationTokenSource(); + } + _ = RunAsync(key, cts, work); + } + + private async Task RunAsync(string key, CancellationTokenSource cts, Func work) + { + try + { + await Task.Delay(_delay, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + lock (_timers) + { + if (_timers.TryGetValue(key, out var current) && ReferenceEquals(current, cts)) + _timers.Remove(key); + } + await work().ConfigureAwait(false); + } + } + + /// + /// The coordinate contract (preregistration §6, gap 1): with a column, the span starts there; + /// without one, it is the line without its leading and trailing whitespace. Returns 0-based + /// [start, end) within the line, and the 1-based column shown. A coordinate, never a verdict. + /// + public static class LiveCoordinates + { + public static (int Start, int End, int Column) Span(string lineText, int? column) + { + var first = 0; + while (first < lineText.Length && char.IsWhiteSpace(lineText[first])) + first++; + var end = lineText.Length; + while (end > first && char.IsWhiteSpace(lineText[end - 1])) + end--; + var start = column is int c && c >= 1 && c - 1 < end ? c - 1 : first; + if (end <= start) + end = Math.Min(lineText.Length, start + 1); + return (start, end, start + 1); + } + } +} diff --git a/frontend/roslyn/Owen.VisualStudio/Live/LiveService.cs b/frontend/roslyn/Owen.VisualStudio/Live/LiveService.cs new file mode 100644 index 00000000..c68f9ffd --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/Live/LiveService.cs @@ -0,0 +1,328 @@ +// OX-02 (preregistration §4, K6): the long-lived `owen serve` child, and the policy that keeps +// it honest. Independent of Visual Studio. +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace Owen.VisualStudio.Live +{ + /// The service process is gone (or never came up): its exit code and last stderr. + public sealed class LiveServiceDiedException : Exception + { + public LiveServiceDiedException(string message) + : base(message) + { + } + } + + /// + /// One dotnet exec <Owen.Build>/ownsharp.dll serve process. Requests are framed on its + /// stdin, answers read from its stdout and matched by id; its stderr is kept (the last lines + /// go into the failure message, every line to ), never discarded. When the + /// process ends, every pending request fails with . + /// + public sealed class LiveService : IDisposable + { + private readonly Process _process; + private readonly Stream _stdin; + private readonly object _writeGate = new object(); + private readonly ConcurrentDictionary> _pending = + new ConcurrentDictionary>(); + private readonly Queue _stderrTail = new Queue(); + private readonly TaskCompletionSource _hello = new TaskCompletionSource(); + private long _nextId; + private string? _death; + + private LiveService(Process process, Action log) + { + _process = process; + Log = log; + _stdin = process.StandardInput.BaseStream; + } + + public Action Log { get; } + + public int ProcessId => _process.Id; + + public bool IsAlive => _death == null && !_process.HasExited; + + /// Why the service is gone, once it is. + public string? Death => _death; + + public event Action? Died; + + /// Start the service and complete the owen-live/1 handshake. + public static async Task StartAsync(string dotnet, string hostDll, Action log, + TimeSpan handshakeTimeout) + { + if (!File.Exists(hostDll)) + throw new LiveServiceDiedException("the Owen service '" + hostDll + "' does not exist (restore the project, or update Owen.Build)"); + var psi = new ProcessStartInfo(dotnet, "exec \"" + hostDll + "\" serve") + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + psi.EnvironmentVariables["DOTNET_ROLL_FORWARD"] = "Major"; + Process process; + try + { + process = Process.Start(psi) ?? throw new LiveServiceDiedException("the Owen service did not start"); + } + catch (System.ComponentModel.Win32Exception ex) + { + throw new LiveServiceDiedException("the Owen service could not be started with '" + dotnet + "': " + ex.Message); + } + ChildProcessGuard.Attach(process); + var service = new LiveService(process, log); + service.Run(); + LiveFrames.Write(service._stdin, LiveFrames.Hello()); + var done = await Task.WhenAny(service._hello.Task, Task.Delay(handshakeTimeout)).ConfigureAwait(false); + if (done != service._hello.Task) + { + service.Kill(); + throw new LiveServiceDiedException("the Owen service did not answer hello within " + handshakeTimeout.TotalSeconds + " s"); + } + var hello = await service._hello.Task.ConfigureAwait(false); + if ((string?)hello["protocol"] != LiveFrames.Protocol) + { + service.Kill(); + throw new LiveServiceDiedException("the Owen service speaks '" + hello["protocol"] + "', not " + LiveFrames.Protocol); + } + return service; + } + + public Task AnalyzeAsync(string key, long version, string request, + IEnumerable documents, IEnumerable generated) + { + var id = Interlocked.Increment(ref _nextId); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = tcs; + if (_death != null) + Fail(_death); + try + { + lock (_writeGate) + LiveFrames.Write(_stdin, LiveFrames.Analyze(id, key, version, request, documents, generated)); + } + catch (IOException ex) + { + Died1("writing to the Owen service failed: " + ex.Message); + } + return tcs.Task; + } + + private void Run() + { + var reader = new Thread(() => + { + try + { + var stdout = _process.StandardOutput.BaseStream; + while (true) + { + var frame = LiveFrames.Read(stdout); + if (frame == null) + break; + var type = (string?)frame["type"]; + if (type == "hello") + _hello.TrySetResult(frame); + else if (type == "result") + { + var response = LiveFrames.Response(frame); + if (_pending.TryRemove(response.Id, out var tcs)) + tcs.TrySetResult(response); + } + else if (type == "fatal") + Log("owen serve: fatal: " + frame["message"]); + else + throw new LiveProtocolException("unknown message type '" + type + "'"); + } + } + catch (Exception ex) when (ex is LiveProtocolException || ex is IOException) + { + Log("owen serve: " + ex.Message); + Kill(); + } + _process.WaitForExit(5000); + Died1(null); + }) + { IsBackground = true, Name = "Owen live service reader" }; + reader.Start(); + + var errors = new Thread(() => + { + string? line; + while ((line = _process.StandardError.ReadLine()) != null) + { + Log("owen serve: " + line); + lock (_stderrTail) + { + _stderrTail.Enqueue(line); + while (_stderrTail.Count > 20) + _stderrTail.Dequeue(); + } + } + }) + { IsBackground = true, Name = "Owen live service stderr" }; + errors.Start(); + } + + private void Died1(string? why) + { + if (_death != null) + return; + string tail; + lock (_stderrTail) + tail = string.Join(" | ", _stderrTail.Where(l => l.Trim().Length > 0)); + var code = _process.HasExited ? _process.ExitCode.ToString(System.Globalization.CultureInfo.InvariantCulture) : "?"; + _death = (why ?? "the Owen live analysis service exited (code " + code + ")") + + (tail.Length > 0 ? ": " + tail : ""); + _hello.TrySetException(new LiveServiceDiedException(_death)); + Fail(_death); + Died?.Invoke(this); + } + + private void Fail(string why) + { + foreach (var id in _pending.Keys.ToList()) + if (_pending.TryRemove(id, out var tcs)) + tcs.TrySetException(new LiveServiceDiedException(why)); + } + + public void Kill() + { + try + { + if (!_process.HasExited) + _process.Kill(); + } + catch (InvalidOperationException) + { + // already gone + } + } + + public void Dispose() + { + try + { + lock (_writeGate) + LiveFrames.Write(_stdin, new JObject { ["type"] = "shutdown" }); + } + catch (IOException) + { + // already gone + } + if (!_process.WaitForExit(2000)) + Kill(); + } + } + + /// + /// K6: the service may die; that is shown, and live analysis restarts on the next request, at + /// most times in . After that it stays off, and + /// says so, until the solution is reopened. Never silent. + /// + public sealed class LiveSupervisor : IDisposable + { + public const int MaxRestarts = 3; + public static readonly TimeSpan Window = TimeSpan.FromMinutes(5); + + private readonly string _dotnet; + private readonly string _host; + private readonly Action _log; + private readonly Func _now; + private readonly List _starts = new List(); + private readonly SemaphoreSlim _gate = new SemaphoreSlim(1, 1); + private LiveService? _service; + + public LiveSupervisor(string dotnet, string host, Action log, Func? now = null) + { + _dotnet = dotnet; + _host = host; + _log = log; + _now = now ?? (() => DateTime.UtcNow); + } + + /// The operational condition to show (OWENV001), or null while healthy. + public string? Condition { get; private set; } + + /// True once the restart budget is spent. + public bool GaveUp { get; private set; } + + public event Action? ConditionChanged; + + public int? ProcessId => _service?.ProcessId; + + public async Task AnalyzeAsync(string key, long version, string request, + IReadOnlyList documents, IReadOnlyList generated) + { + var service = await EnsureAsync().ConfigureAwait(false); + return await service.AnalyzeAsync(key, version, request, documents, generated).ConfigureAwait(false); + } + + private async Task EnsureAsync() + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + if (_service != null && _service.IsAlive) + return _service; + if (GaveUp) + throw new LiveServiceDiedException(Condition ?? "Owen live analysis is off"); + var now = _now(); + _starts.RemoveAll(t => now - t > Window); + if (_service != null && _starts.Count > MaxRestarts) + { + GaveUp = true; + SetCondition((Condition ?? "the Owen live analysis service stopped") + + ". It was restarted " + MaxRestarts + " times in " + Window.TotalMinutes + + " minutes; live analysis is off until the solution is reopened (builds still run Owen)."); + throw new LiveServiceDiedException(Condition!); + } + _starts.Add(now); + try + { + _service = await LiveService.StartAsync(_dotnet, _host, _log, TimeSpan.FromSeconds(60)).ConfigureAwait(false); + } + catch (LiveServiceDiedException ex) + { + SetCondition(ex.Message); + throw; + } + _service.Died += s => + { + if (ReferenceEquals(s, _service)) + SetCondition(s.Death); + }; + if (Condition != null && !GaveUp) + SetCondition(null); + return _service; + } + finally + { + _gate.Release(); + } + } + + private void SetCondition(string? condition) + { + Condition = condition; + ConditionChanged?.Invoke(this); + } + + /// Test hook and K6 drill: kill the running service as a crash would. + public void KillService() => _service?.Kill(); + + public void Dispose() => _service?.Dispose(); + } +} diff --git a/frontend/roslyn/Owen.VisualStudio/Owen.VisualStudio.csproj b/frontend/roslyn/Owen.VisualStudio/Owen.VisualStudio.csproj new file mode 100644 index 00000000..333001c9 --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/Owen.VisualStudio.csproj @@ -0,0 +1,78 @@ + + + + net472 + Library + latest + enable + Owen.VisualStudio + Owen.VisualStudio + 0.1.0 + false + false + true + true + true + true + true + Owen.VisualStudio.vsix + true + $(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\$(TargetVsixContainerName) + $(MSBuildProjectDirectory)\obj\$(Configuration)\$(TargetFramework)\$(AssemblyName).dll + + $(BaseIntermediateOutputPath)$(Configuration)\$(TargetFramework)\vsix\ + $(VsixIntermediateOutputPath)extension.vsixmanifest + $(VsixIntermediateOutputPath)files.json + $(VsixIntermediateOutputPath)templateFiles.json + $(VsixIntermediateOutputPath)resources.json + $(VsixIntermediateOutputPath)ctoFiles.json + $(VsixIntermediateOutputPath)mergeCto.cache + $(VsixIntermediateOutputPath)createTemplateManifests.cache + $(VsixIntermediateOutputPath) + + $(NoWarn);VSTHRD003;VSTHRD010;VSTHRD100;VSTHRD110;VSTHRD200;VSSDK007 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/roslyn/Owen.VisualStudio/OwenErrorListSource.cs b/frontend/roslyn/Owen.VisualStudio/OwenErrorListSource.cs new file mode 100644 index 00000000..79e8d0ab --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/OwenErrorListSource.cs @@ -0,0 +1,144 @@ +// OX-02: Owen's Error List rows — one ITableDataSource on the standard errors table, one table +// snapshot per project, replaced whole on every publication. The pattern is the VSSDK ErrorList +// sample's (nothing copied). Rows carry DocumentName/Line/Column, which is what the Error List +// navigates by on a double-click. +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.Shell.Interop; +using Microsoft.VisualStudio.Shell.TableControl; +using Microsoft.VisualStudio.Shell.TableManager; +using Owen.VisualStudio.Live; + +namespace Owen.VisualStudio +{ + internal sealed class OwenErrorListSource : ITableDataSource + { + private readonly List _sinks = new List(); + private readonly Dictionary _snapshots = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private int _version; + + public OwenErrorListSource(ITableManager manager) + { + manager.AddSource(this, + StandardTableColumnDefinitions.ErrorSeverity, + StandardTableColumnDefinitions.ErrorCode, + StandardTableColumnDefinitions.Text, + StandardTableColumnDefinitions.ProjectName, + StandardTableColumnDefinitions.DocumentName, + StandardTableColumnDefinitions.Line, + StandardTableColumnDefinitions.Column, + StandardTableColumnDefinitions.BuildTool, + StandardTableColumnDefinitions.ErrorSource); + } + + public string SourceTypeIdentifier => StandardTableDataSources.ErrorTableDataSource; + + public string Identifier => "Owen"; + + public string DisplayName => "Owen"; + + public IDisposable Subscribe(ITableDataSink sink) + { + lock (_sinks) + { + _sinks.Add(sink); + foreach (var s in _snapshots.Values) + sink.AddSnapshot(s); + } + return new Unsubscriber(this, sink); + } + + public void Publish(string key, IReadOnlyList rows) + { + lock (_sinks) + { + _snapshots.TryGetValue(key, out var old); + var next = new OwenEntriesSnapshot(rows, ++_version); + _snapshots[key] = next; + foreach (var sink in _sinks) + { + if (old != null) + sink.ReplaceSnapshot(old, next); + else + sink.AddSnapshot(next); + } + } + } + + private sealed class Unsubscriber : IDisposable + { + private readonly OwenErrorListSource _source; + private readonly ITableDataSink _sink; + + public Unsubscriber(OwenErrorListSource source, ITableDataSink sink) + { + _source = source; + _sink = sink; + } + + public void Dispose() + { + lock (_source._sinks) + _source._sinks.Remove(_sink); + } + } + } + + internal sealed class OwenEntriesSnapshot : TableEntriesSnapshotBase + { + private readonly IReadOnlyList _rows; + private readonly int _version; + + public OwenEntriesSnapshot(IReadOnlyList rows, int version) + { + _rows = rows; + _version = version; + } + + public override int Count => _rows.Count; + + public override int VersionNumber => _version; + + public override bool TryGetValue(int index, string keyName, out object? content) + { + content = null; + if (index < 0 || index >= _rows.Count) + return false; + var row = _rows[index]; + switch (keyName) + { + case StandardTableKeyNames.ErrorSeverity: + content = row.Severity == "error" ? __VSERRORCATEGORY.EC_ERROR : __VSERRORCATEGORY.EC_WARNING; + return true; + case StandardTableKeyNames.ErrorCode: + content = row.Code; + return true; + case StandardTableKeyNames.Text: + content = row.Message; + return true; + case StandardTableKeyNames.ProjectName: + content = row.Project; + return true; + case StandardTableKeyNames.DocumentName: + content = row.File; + return true; + case StandardTableKeyNames.Line: + // 0-based in the table; a file-level row has no line + content = row.Line is int line ? line - 1 : -1; + return row.Line != null; + case StandardTableKeyNames.Column: + content = row.Column is int column ? column - 1 : 0; + return row.Line != null; + case StandardTableKeyNames.BuildTool: + content = "Owen"; + return true; + case StandardTableKeyNames.ErrorSource: + content = ErrorSource.Other; + return true; + default: + return false; + } + } + } +} diff --git a/frontend/roslyn/Owen.VisualStudio/OwenErrorTagger.cs b/frontend/roslyn/Owen.VisualStudio/OwenErrorTagger.cs new file mode 100644 index 00000000..14499a18 --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/OwenErrorTagger.cs @@ -0,0 +1,145 @@ +// OX-02: Owen's squiggles. One tagger per C# buffer; it shows what OwenLiveMonitor published for +// the buffer's file — the finding's own span and its witness steps — mapped onto the current text. +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Diagnostics; +using System.Linq; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Adornments; +using Microsoft.VisualStudio.Text.Tagging; +using Microsoft.VisualStudio.Utilities; +using Owen.VisualStudio.Live; + +namespace Owen.VisualStudio +{ + [Export(typeof(ITaggerProvider))] + [ContentType("CSharp")] + [TagType(typeof(IErrorTag))] + internal sealed class OwenErrorTaggerProvider : ITaggerProvider + { + [Import] + internal OwenLiveMonitor Monitor { get; set; } = null!; + + public ITagger? CreateTagger(ITextBuffer buffer) + where T : ITag + { + if (!buffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument document)) + return null; + return buffer.Properties.GetOrCreateSingletonProperty( + typeof(OwenErrorTagger), () => new OwenErrorTagger(buffer, document, Monitor)) as ITagger; + } + } + + internal sealed class OwenErrorTagger : ITagger + { + private readonly ITextBuffer _buffer; + private readonly ITextDocument _document; + private readonly OwenLiveMonitor _monitor; + private string _lastTrace = ""; + + public OwenErrorTagger(ITextBuffer buffer, ITextDocument document, OwenLiveMonitor monitor) + { + _buffer = buffer; + _document = document; + _monitor = monitor; + _monitor.FileChanged += OnFileChanged; + _monitor.Opened(document.FilePath); + } + + public event EventHandler? TagsChanged; + + private void OnFileChanged(string file) + { + if (!string.Equals(System.IO.Path.GetFullPath(file), System.IO.Path.GetFullPath(_document.FilePath), + StringComparison.OrdinalIgnoreCase)) + return; + // Publications arrive on a background thread; the editor's tag aggregators expect + // TagsChanged on the UI thread. + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + try + { + var snapshot = _buffer.CurrentSnapshot; + TagsChanged?.Invoke(this, new SnapshotSpanEventArgs(new SnapshotSpan(snapshot, 0, snapshot.Length))); + } + catch (Exception ex) + { + OwenTrace.Event("error", new { where = "TagsChanged", error = ex.ToString() }); + } + }); + } + + public IEnumerable> GetTags(NormalizedSnapshotSpanCollection spans) + { + try + { + return Tags(spans); + } + catch (Exception ex) + { + // a tagger that throws is switched off by the editor: report, show nothing this time + OwenTrace.Event("error", new { where = "GetTags", error = ex.ToString() }); + OwenLog.Write("Owen: squiggles failed: " + ex.Message); + return Array.Empty>(); + } + } + + private IEnumerable> Tags(NormalizedSnapshotSpanCollection spans) + { + if (spans.Count == 0) + return Array.Empty>(); + var clock = Stopwatch.StartNew(); + var snapshot = spans[0].Snapshot; + var tags = new List>(); + var traced = new List(); + foreach (var placed in _monitor.EntriesFor(_document.FilePath)) + { + var span = Locate(placed, snapshot); + if (span is not SnapshotSpan s) + continue; + var entry = placed.Entry; + var type = entry.Severity == "error" ? PredefinedErrorTypeNames.SyntaxError : PredefinedErrorTypeNames.Warning; + var tip = entry.Code + ": " + entry.Message; + traced.Add(new + { + code = entry.Code, + primary = entry.Primary, + line = s.Start.GetContainingLine().LineNumber + 1, + column = s.Start.Position - s.Start.GetContainingLine().Start.Position + 1, + length = s.Length, + text = s.GetText(), + }); + if (spans.IntersectsWith(new NormalizedSnapshotSpanCollection(s))) + tags.Add(new TagSpan(s, new ErrorTag(type, tip))); + } + var key = snapshot.Version.VersionNumber + "|" + string.Join(";", traced.Select(t => t.ToString())); + if (key != _lastTrace) + { + _lastTrace = key; + OwenTrace.Event("tags", new { file = _document.FilePath, snapshot = snapshot.Version.VersionNumber, tags = traced }); + } + OwenTrace.Ui("GetTags", clock.Elapsed.TotalMilliseconds); + return tags; + } + + private static SnapshotSpan? Locate(PlacedEntry placed, ITextSnapshot snapshot) + { + if (placed.Span != null) + { + if (placed.Span.TextBuffer != snapshot.TextBuffer) + return null; + var s = placed.Span.GetSpan(snapshot); + return s.Length > 0 ? s : (SnapshotSpan?)null; // the text it named was deleted + } + if (placed.Entry.Line is not int line || line > snapshot.LineCount) + return null; + var l = snapshot.GetLineFromLineNumber(line - 1); + var start = Math.Min(l.Start.Position + placed.Entry.Start, l.End.Position); + var end = Math.Max(start, Math.Min(l.Start.Position + placed.Entry.End, l.End.Position)); + return end > start ? new SnapshotSpan(snapshot, Span.FromBounds(start, end)) : (SnapshotSpan?)null; + } + } +} diff --git a/frontend/roslyn/Owen.VisualStudio/OwenLiveMonitor.cs b/frontend/roslyn/Owen.VisualStudio/OwenLiveMonitor.cs new file mode 100644 index 00000000..a351c326 --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/OwenLiveMonitor.cs @@ -0,0 +1,220 @@ +// OX-02: the one MEF part that connects Visual Studio's Roslyn workspace to LiveEngine. It knows +// no Owen extension. For a project it sends the service the unsaved text of the project's open +// documents and its source-generated documents, exactly as the workspace holds them now. +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServices; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.TableManager; +using Microsoft.VisualStudio.Text; +using Owen.VisualStudio.Live; + +namespace Owen.VisualStudio +{ + /// What a published entry needs in the editor: where it is now. + internal sealed class PlacedEntry + { + public PlacedEntry(LiveEntry entry, ITrackingSpan? span) + { + Entry = entry; + Span = span; + } + + public LiveEntry Entry { get; } + + /// The squiggle's span on the text the analysis read, tracked forward; null when + /// that text was not an editor buffer (then the line is located on the current text). + public ITrackingSpan? Span { get; } + } + + [Export(typeof(OwenLiveMonitor))] + [PartCreationPolicy(CreationPolicy.Shared)] + internal sealed class OwenLiveMonitor + { + private readonly VisualStudioWorkspace _workspace; + private readonly OwenErrorListSource _errorList; + private readonly LiveEngine _engine; + private readonly ConcurrentDictionary _supervisors = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + // project -> what its newest publication placed + private readonly ConcurrentDictionary> _placed = + new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + + [ImportingConstructor] + public OwenLiveMonitor(VisualStudioWorkspace workspace, ITableManagerProvider tables) + { + _workspace = workspace; + _errorList = new OwenErrorListSource(tables.GetTableManager(StandardTables.ErrorsTable)); + _engine = new LiveEngine(SnapshotAsync, AnalyzerFor, OwenLog.Write, TimeSpan.FromMilliseconds(250)); + _engine.Published += OnPublished; + _workspace.WorkspaceChanged += OnWorkspaceChanged; + OwenLog.Write("Owen live analysis loaded"); + } + + /// A file's entries changed (its tagger re-reads them). + public event Action? FileChanged; + + public IReadOnlyList EntriesFor(string file) + { + var full = Path.GetFullPath(file); + return _placed.Values.SelectMany(p => p) + .Where(p => string.Equals(Path.GetFullPath(p.Entry.File), full, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// An editor opened : analyse its project. + public void Opened(string file) + { + var key = ProjectOf(file); + if (key != null) + _engine.OnEdit(key); + } + + private string? ProjectOf(string file) + { + var solution = _workspace.CurrentSolution; + foreach (var id in solution.GetDocumentIdsWithFilePath(file)) + if (solution.GetProject(id.ProjectId)?.FilePath is string path) + return path; + return null; + } + + private void OnWorkspaceChanged(object sender, WorkspaceChangeEventArgs e) + { + switch (e.Kind) + { + case WorkspaceChangeKind.DocumentChanged: + case WorkspaceChangeKind.DocumentAdded: + case WorkspaceChangeKind.DocumentRemoved: + case WorkspaceChangeKind.DocumentReloaded: + case WorkspaceChangeKind.ProjectChanged: + case WorkspaceChangeKind.ProjectAdded: + case WorkspaceChangeKind.ProjectReloaded: + if (e.ProjectId != null && e.NewSolution.GetProject(e.ProjectId)?.FilePath is string key + && HasOpenDocument(e.NewSolution.GetProject(e.ProjectId)!)) + { + OwenTrace.Event("edit", new { key, kind = e.Kind.ToString() }); + _engine.OnEdit(key); + } + break; + } + } + + private bool HasOpenDocument(Project project) => project.DocumentIds.Any(_workspace.IsDocumentOpen); + + private async Task SnapshotAsync(string key) + { + var request = LiveRequestFile.For(key); + if (LiveRequestFile.TryRead(request) == null) + { + OwenTrace.Event("no-request", new { key, request }); + return null; + } + var project = _workspace.CurrentSolution.Projects.FirstOrDefault( + p => string.Equals(p.FilePath, key, StringComparison.OrdinalIgnoreCase)); + if (project == null) + return null; + var documents = new List(); + var texts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var document in project.Documents) + { + if (document.FilePath == null || !_workspace.IsDocumentOpen(document.Id)) + continue; + var text = await document.GetTextAsync().ConfigureAwait(false); + texts[Path.GetFullPath(document.FilePath)] = text; + documents.Add(new LiveSource(document.FilePath, text.ToString())); + } + var generated = new List(); + foreach (var document in await project.GetSourceGeneratedDocumentsAsync().ConfigureAwait(false)) + { + if (document.FilePath == null) + continue; + var text = await document.GetTextAsync().ConfigureAwait(false); + generated.Add(new LiveSource(document.FilePath, text.ToString())); + } + OwenTrace.Event("snapshot", new + { + key, + documents = documents.Select(d => d.Path), + generated = generated.Select(g => g.Path), + }); + return new LiveSnapshot(key, request, documents, generated, texts); + } + + private ILiveAnalyzer? AnalyzerFor(LiveSnapshot snapshot) + { + var request = LiveRequestFile.TryRead(snapshot.Request); + if (request == null) + return null; + var supervisor = _supervisors.GetOrAdd(request.Host, host => + { + var s = new LiveSupervisor(Dotnet(request), host, OwenLog.Write); + s.ConditionChanged += _ => OwenTrace.Event("service", new { host, condition = s.Condition, gave_up = s.GaveUp }); + return s; + }); + return new SupervisedAnalyzer(supervisor); + } + + /// The muxer: the one MSBuild named, else the machine's, else PATH's. + private static string Dotnet(LiveRequestFile request) + { + if (request.Dotnet != null && File.Exists(request.Dotnet)) + return request.Dotnet; + var programFiles = Environment.GetEnvironmentVariable("ProgramW6432") + ?? Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var machine = Path.Combine(programFiles, "dotnet", "dotnet.exe"); + return File.Exists(machine) ? machine : "dotnet"; + } + + private void OnPublished(LivePublication publication) + { + var texts = publication.Snapshot?.State as Dictionary; + var placed = new List(); + foreach (var entry in publication.Entries) + { + ITrackingSpan? span = null; + if (entry.Line is int line && texts != null + && texts.TryGetValue(Path.GetFullPath(entry.File), out var text) + && text.FindCorrespondingEditorTextSnapshot() is ITextSnapshot snapshot + && line <= snapshot.LineCount) + { + var l = snapshot.GetLineFromLineNumber(line - 1); + var start = Math.Min(l.Start.Position + entry.Start, l.End.Position); + var end = Math.Max(start, Math.Min(l.Start.Position + entry.End, l.End.Position)); + span = snapshot.CreateTrackingSpan(Span.FromBounds(start, end), SpanTrackingMode.EdgeExclusive); + } + placed.Add(new PlacedEntry(entry, span)); + } + + var files = new HashSet(StringComparer.OrdinalIgnoreCase); + if (_placed.TryGetValue(publication.Key, out var old)) + foreach (var p in old) + files.Add(Path.GetFullPath(p.Entry.File)); + foreach (var p in placed) + files.Add(Path.GetFullPath(p.Entry.File)); + _placed[publication.Key] = placed; + + _errorList.Publish(publication.Key, publication.Entries.Where(e => e.Primary).ToList()); + OwenTrace.Event("publish", new + { + key = publication.Key, + version = publication.Version, + timing = publication.Timing, + entries = publication.Entries.Select(e => new + { + code = e.Code, severity = e.Severity, file = e.File, line = e.Line, column = e.Column, + start = e.Start, end = e.End, primary = e.Primary, message = e.Message, + }), + }); + foreach (var file in files) + FileChanged?.Invoke(file); + } + } +} diff --git a/frontend/roslyn/Owen.VisualStudio/OwenLog.cs b/frontend/roslyn/Owen.VisualStudio/OwenLog.cs new file mode 100644 index 00000000..2f291b1e --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/OwenLog.cs @@ -0,0 +1,75 @@ +// OX-02: where Owen.VisualStudio's own words go. The "Owen" output pane (the service's stderr +// included: never discarded), and an opt-in machine-readable trace for the real-IDE acceptance +// run (OWEN_LIVE_TRACE=): what was published, which tags the tagger produced on which text +// version, and how long each UI-thread handler took. The trace never changes behaviour. +using System; +using System.Diagnostics; +using System.IO; +using Microsoft.VisualStudio.Shell; +using Microsoft.VisualStudio.Shell.Interop; +using Newtonsoft.Json; + +namespace Owen.VisualStudio +{ + internal static class OwenLog + { + private static readonly Guid PaneId = new Guid("a6b1a0f4-2c55-4e43-9a8b-9d0f2b8e5f31"); + private static IVsOutputWindowPane? _pane; + private static bool _creating; + + public static void Write(string line) + { + OwenTrace.Event("log", new { line }); + var pane = _pane; + if (pane != null) + { + pane.OutputStringThreadSafe(line + Environment.NewLine); + return; + } + if (_creating) + return; + _creating = true; + _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () => + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + if (Package.GetGlobalService(typeof(SVsOutputWindow)) is IVsOutputWindow window) + { + var id = PaneId; + window.CreatePane(ref id, "Owen", 1, 0); + window.GetPane(ref id, out _pane); + } + _pane?.OutputStringThreadSafe(line + Environment.NewLine); + }); + } + } + + internal static class OwenTrace + { + private static readonly string? Path = Environment.GetEnvironmentVariable("OWEN_LIVE_TRACE"); + private static readonly object Gate = new object(); + private static double _uiMax; + + public static void Event(string kind, object data) + { + if (Path == null) + return; + var line = JsonConvert.SerializeObject(new + { + t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + kind, + data, + }); + lock (Gate) + File.AppendAllText(Path, line + "\n"); + } + + /// A UI-thread handler's duration; traced when it sets a new maximum. + public static void Ui(string handler, double ms) + { + if (Path == null || ms <= _uiMax) + return; + _uiMax = ms; + Event("ui", new { handler, ms, pid = Process.GetCurrentProcess().Id }); + } + } +} diff --git a/frontend/roslyn/Owen.VisualStudio/source.extension.vsixmanifest b/frontend/roslyn/Owen.VisualStudio/source.extension.vsixmanifest new file mode 100644 index 00000000..8373fa85 --- /dev/null +++ b/frontend/roslyn/Owen.VisualStudio/source.extension.vsixmanifest @@ -0,0 +1,28 @@ + + + + + + Owen + Live Owen diagnostics while you type, for every Owen extension a project uses (squiggles and Error List). Analysis is done by the Owen build host the project already references (Owen.Build). + owen, ownership, static analysis, csharp + + + + amd64 + + + amd64 + + + amd64 + + + + + + + + + + diff --git a/frontend/roslyn/OwnSharp.Cli/BuildCheckCommand.cs b/frontend/roslyn/OwnSharp.Cli/BuildCheckCommand.cs index 34e168f2..bfeb33f5 100644 --- a/frontend/roslyn/OwnSharp.Cli/BuildCheckCommand.cs +++ b/frontend/roslyn/OwnSharp.Cli/BuildCheckCommand.cs @@ -1,6 +1,4 @@ using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; namespace OwnSharp.Cli; @@ -25,25 +23,14 @@ namespace OwnSharp.Cli; /// (--format msbuild) and passed through byte for byte. This host renders nothing of /// its own and knows no extension by name. /// +/// The host contract itself (descriptors, the input set, refusals) is +/// , shared with the live IDE service (owen serve). +/// /// 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") @@ -55,7 +42,7 @@ public static async Task RunAsync(string[] args) Dictionary> request; try { - request = ReadRequest(args[1]); + request = OwenHost.ReadRequest(args[1]); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -63,11 +50,11 @@ public static async Task RunAsync(string[] args) 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"); + var project = OwenHost.One(request, "project"); + var severity = OwenHost.One(request, "severity") ?? "warning"; + var manifest = OwenHost.One(request, "manifest"); + var generatedRoot = OwenHost.One(request, "generated-root"); + var emitFacts = OwenHost.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"); @@ -84,43 +71,20 @@ public static async Task RunAsync(string[] args) if (paths.Count == 0) { Console.WriteLine($"{project}: warning OWENB001: Owen.Build is referenced but no Owen extension is active; nothing was analysed"); - WriteManifest(manifest, []); + OwenHost.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) + var errors = new List(); + var descriptors = OwenHost.Validate(paths, errors); + foreach (var e in errors) + Console.WriteLine(e.ToMsBuild()); + if (descriptors is null) return 2; - descriptors.Sort((a, b) => StringComparer.Ordinal.Compare(a.Id, b.Id)); - WriteManifest(manifest, descriptors); + OwenHost.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)); - } - } + var inputs = OwenHost.Inputs(project, generatedRoot, descriptors); // ---- 3. the engine ------------------------------------------------------------------ RustCore core; @@ -130,7 +94,7 @@ public static async Task RunAsync(string[] args) } catch (RustCoreNotResolvedException ex) { - Console.WriteLine($"{project}: error OWENB005: {OneLine(ex.Message)}"); + Console.WriteLine($"{project}: error OWENB005: {OwenHost.OneLine(ex.Message)}"); return 2; } @@ -142,13 +106,13 @@ public static async Task RunAsync(string[] args) .ConfigureAwait(false); if (extractRc == 2) { - foreach (var line in Canonical(extractOutput, project, "OWENB010", ExtractorRefusal)) - Console.WriteLine(Absolute(line, project)); + foreach (var d in OwenHost.Refusals(extractOutput, project, "OWENB010", OwenHost.ExtractorRefusal)) + Console.WriteLine(d.ToMsBuild()); return 2; } if (extractRc != 0) { - Console.WriteLine($"{project}: error OWENB012: the extractor exited {extractRc}: {OneLine(extractOutput)}"); + Console.WriteLine($"{project}: error OWENB012: the extractor exited {extractRc}: {OwenHost.OneLine(extractOutput)}"); return 2; } if (emitFacts is not null) @@ -162,7 +126,7 @@ public static async Task RunAsync(string[] args) } catch (EngineRunner.RustCoreNotStartedException ex) { - Console.WriteLine($"{project}: error OWENB005: {OneLine(ex.Message)}"); + Console.WriteLine($"{project}: error OWENB005: {OwenHost.OneLine(ex.Message)}"); return 2; } var stdout = Encoding.UTF8.GetString(outcome.Stdout); @@ -173,15 +137,15 @@ public static async Task RunAsync(string[] args) // 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)); + Console.Write(OwenHost.Absolute(stdout, project)); + Console.Write(OwenHost.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)); + foreach (var d in OwenHost.Refusals(stderr + stdout, project, "OWENB011", OwenHost.CoreRefusal)) + Console.WriteLine(d.ToMsBuild()); return 2; default: - Console.WriteLine($"{project}: error OWENB012: the Rust core exited {outcome.Rc}, which is not a verdict: {OneLine(stderr)}"); + Console.WriteLine($"{project}: error OWENB012: the Rust core exited {outcome.Rc}, which is not a verdict: {OwenHost.OneLine(stderr)}"); return 2; } } @@ -190,208 +154,4 @@ public static async Task RunAsync(string[] args) 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/LiveAnalysis.cs b/frontend/roslyn/OwnSharp.Cli/LiveAnalysis.cs new file mode 100644 index 00000000..daeb4f74 --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/LiveAnalysis.cs @@ -0,0 +1,241 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using OwnSharp.Extractor; + +namespace OwnSharp.Cli; + +/// One source an IDE holds in memory: its full path and its current text. +internal sealed record LiveDocument(string Path, string Text); + +/// A location the core names: the finding's own, or one of its witness steps. +internal sealed record LiveLocation(string File, int Line, int? Column, string? Message); + +/// One diagnostic as the IDE shows it (docs/notes/owen-visual-studio-preregistration.md §6). +/// An OWN finding is the core's Finding, carried; an OWENB one is the host's own. +internal sealed record LiveDiagnostic(string Code, string Severity, string Message, string File, int? Line, + int? Column, string Origin, IReadOnlyList Related); + +internal sealed record LiveResult(IReadOnlyList Diagnostics, IReadOnlyList Extensions, + IReadOnlyDictionary TimingMs); + +/// +/// One live analysis (OX-02): the build host's contract () over the text an +/// IDE holds, through the ONE extractor run in-process () and +/// the packaged Rust core. It is owen build-check with three differences, none of them +/// analysis: the sources come from memory where the IDE has them, the extractor runs in this +/// process, and the core's findings come back through its SARIF renderer so that columns and +/// witness steps survive. A verdict is never formed here. +/// +internal static class LiveAnalysis +{ + private static RustCore? _core; + + public static async Task RunAsync(string requestPath, IReadOnlyList documents, + IReadOnlyList generated) + { + var timing = new Dictionary(StringComparer.Ordinal); + var clock = Stopwatch.StartNew(); + var diagnostics = new List(); + LiveResult Done(IReadOnlyList extensions) + { + timing["total"] = clock.ElapsedMilliseconds; + // the service's own footprint, so a host can watch it grow (preregistration §8) + timing["working_set_kb"] = Environment.WorkingSet / 1024; + return new LiveResult(diagnostics, extensions, timing); + } + void Host(HostDiagnostic d) => + diagnostics.Add(new(d.Code, d.Severity, d.Text, d.File, d.Line, null, "host", [])); + + Dictionary> request; + try + { + request = OwenHost.ReadRequest(requestPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Host(new(requestPath, null, "error", "OWENB012", $"cannot read the live request: {ex.Message}")); + return Done([]); + } + var project = OwenHost.One(request, "project"); + var severity = OwenHost.One(request, "severity") ?? "warning"; + var generatedRoot = OwenHost.One(request, "generated-root"); + if (project is null) + { + Host(new(requestPath, null, "error", "OWENB012", "the live request names no project")); + return Done([]); + } + if (severity is not ("warning" or "error")) + { + Host(new(project, null, "error", "OWENB002", $"OwenSeverity must be 'warning' or 'error', not '{severity}'")); + return Done([]); + } + + // ---- 1. the extensions: the build host's own validation --------------------------- + var paths = request.TryGetValue("descriptor", out var given) ? given : []; + if (paths.Count == 0) + { + Host(new(project, null, "warning", "OWENB001", "Owen.Build is referenced but no Owen extension is active; nothing was analysed")); + return Done([]); + } + var errors = new List(); + var descriptors = OwenHost.Validate(paths, errors); + errors.ForEach(Host); + if (descriptors is null) + return Done([]); + var extensions = descriptors.Select(d => d.Id).ToList(); + + // ---- 2. the inputs: the build's input set, the editor's text --------------------- + var overlay = new Dictionary(StringComparer.Ordinal); + foreach (var d in documents) + overlay[Path.GetFullPath(d.Path)] = d.Text; + Dictionary>? live = null; + if (generatedRoot is not null) + { + live = []; + foreach (var generator in OwenHost.Generators(descriptors)) + foreach (var g in generated) + if (Relocate(g.Path, generator, generatedRoot) is { } path) + { + overlay[path] = g.Text; + (live.TryGetValue(generator, out var list) ? list : live[generator] = []).Add(path); + } + } + var inputs = OwenHost.Inputs(project, generatedRoot, descriptors, live); + var projectDir = Path.GetDirectoryName(Path.GetFullPath(project))!; + + // ---- 3. the extractor, in this process --------------------------------------------- + var factsPath = Path.GetTempFileName(); + try + { + var step = Stopwatch.StartNew(); + var (extractRc, extractOutput) = InProcessExtractor.Run( + [.. inputs, "-o", factsPath, "--flow-locals"], projectDir, overlay); + timing["extract"] = step.ElapsedMilliseconds; + if (extractRc == 2) + { + OwenHost.Refusals(extractOutput, project, "OWENB010", OwenHost.ExtractorRefusal).ForEach(Host); + return Done(extensions); + } + if (extractRc != 0) + { + Host(new(project, null, "error", "OWENB012", $"the extractor exited {extractRc}: {OwenHost.OneLine(extractOutput)}")); + return Done(extensions); + } + + // ---- 4. the core --------------------------------------------------------------- + step.Restart(); + EngineOutcome outcome; + try + { + _core ??= RustCoreLocator.Resolve(); + outcome = await EngineRunner.RunRustAsync(_core, factsPath, "sarif", severity, capture: true) + .ConfigureAwait(false); + } + catch (RustCoreNotResolvedException ex) + { + Host(new(project, null, "error", "OWENB005", OwenHost.OneLine(ex.Message))); + return Done(extensions); + } + catch (EngineRunner.RustCoreNotStartedException ex) + { + _core = null; + Host(new(project, null, "error", "OWENB005", OwenHost.OneLine(ex.Message))); + return Done(extensions); + } + timing["core"] = step.ElapsedMilliseconds; + var stdout = Encoding.UTF8.GetString(outcome.Stdout); + var stderr = Encoding.UTF8.GetString(outcome.Stderr); + switch (outcome.Rc) + { + case 0 or 1: + diagnostics.AddRange(FromSarif(stdout, projectDir)); + break; + case 2: + OwenHost.Refusals(stderr + stdout, project, "OWENB011", OwenHost.CoreRefusal).ForEach(Host); + break; + default: + Host(new(project, null, "error", "OWENB012", $"the Rust core exited {outcome.Rc}, which is not a verdict: {OwenHost.OneLine(stderr)}")); + break; + } + return Done(extensions); + } + finally + { + try { File.Delete(factsPath); } catch (IOException) { /* best-effort */ } + } + } + + /// + /// Where the build would have written an in-memory generated document: the compiler lays a + /// generator's output out as <generated-root>/<generator assembly>/<generator type>/<hint>, + /// and an IDE names its generated documents with the same three trailing parts under a base of + /// its own. The generator is matched by name, never by which extension it belongs to. + /// + internal static string? Relocate(string path, string generator, string generatedRoot) + { + var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries); + for (var i = parts.Length - 3; i >= 0; i--) + if (string.Equals(parts[i], generator, StringComparison.Ordinal)) + return Path.GetFullPath(Path.Combine([generatedRoot, .. parts[i..]])); + return null; + } + + /// + /// The core's findings as the SARIF renderer wrote them, carried field for field: rule id, + /// message text, location, witness steps. Exactly the findings the msbuild renderer prints: + /// a suppressed one ([OwnIgnore]) is not shown there and is not shown here, and an + /// advisory (SARIF note) is a warning there and a warning here. + /// + internal static IEnumerable FromSarif(string sarif, string projectDir) + { + using var doc = JsonDocument.Parse(sarif); + foreach (var run in doc.RootElement.GetProperty("runs").EnumerateArray()) + { + if (!run.TryGetProperty("results", out var results)) + continue; + foreach (var r in results.EnumerateArray()) + { + if (r.TryGetProperty("suppressions", out var sup) && sup.GetArrayLength() > 0) + continue; + var level = r.GetProperty("level").GetString(); + var primary = Location(r.GetProperty("locations")[0], projectDir, null); + var related = new List(); + if (r.TryGetProperty("codeFlows", out var flows)) + foreach (var flow in flows.EnumerateArray()) + foreach (var thread in flow.GetProperty("threadFlows").EnumerateArray()) + foreach (var step in thread.GetProperty("locations").EnumerateArray()) + if (step.TryGetProperty("location", out var at)) + related.Add(Location(at, projectDir, Text(at))); + if (r.TryGetProperty("relatedLocations", out var rel)) + foreach (var at in rel.EnumerateArray()) + related.Add(Location(at, projectDir, Text(at))); + yield return new LiveDiagnostic( + r.GetProperty("ruleId").GetString()!, + level == "error" ? "error" : "warning", + r.GetProperty("message").GetProperty("text").GetString()!, + primary.File, primary.Line, primary.Column, "core", related); + } + } + + static string? Text(JsonElement at) => + at.TryGetProperty("message", out var m) && m.TryGetProperty("text", out var t) ? t.GetString() : null; + } + + private static LiveLocation Location(JsonElement at, string projectDir, string? message) + { + var physical = at.GetProperty("physicalLocation"); + var uri = physical.GetProperty("artifactLocation").GetProperty("uri").GetString()!; + var file = Path.IsPathRooted(uri) ? uri : Path.GetFullPath(Path.Combine(projectDir, uri)); + int line = 0; + int? column = null; + if (physical.TryGetProperty("region", out var region)) + { + if (region.TryGetProperty("startLine", out var l)) + line = l.GetInt32(); + if (region.TryGetProperty("startColumn", out var c)) + column = c.GetInt32(); + } + return new LiveLocation(file, line, column, message); + } +} diff --git a/frontend/roslyn/OwnSharp.Cli/OwenHost.cs b/frontend/roslyn/OwnSharp.Cli/OwenHost.cs new file mode 100644 index 00000000..2b5cdd03 --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/OwenHost.cs @@ -0,0 +1,306 @@ +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace OwnSharp.Cli; + +/// One condition of the host itself (an OWENB code): a descriptor rejected, no +/// engine, a refusal. Never an analysis finding; never an OWN code. +internal sealed record HostDiagnostic(string File, int? Line, string Severity, string Code, string Text) +{ + /// The canonical MSBuild line: file(line): severity CODE: text. + public string ToMsBuild() => + Line is { } line ? $"{File}({line}): {Severity} {Code}: {Text}" : $"{File}: {Severity} {Code}: {Text}"; +} + +/// An active Owen extension, as its descriptor declares it. +internal sealed record OwenDescriptor(string Path, string Id, string Version, IReadOnlyList Capabilities, + IReadOnlyList Generators); + +/// +/// The generic Owen host contract (OX-01, spec/OwenExtension.md), shared by its two front +/// doors: owen build-check (after a build) and owen serve (live, for an IDE; +/// OX-02). Everything here decides WHAT is analysed and how the host's own conditions read; +/// nothing here analyses, and nothing here names an extension. +/// +internal static class OwenHost +{ + /// 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"]; + + // ---- the request file --------------------------------------------------------------------- + + /// The `key\tvalue` request the Owen.Build targets write (request.txt for a build, + /// live.txt for an IDE). A key may repeat (`descriptor`). + public 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; + } + + public static string? One(Dictionary> map, string key) => + map.TryGetValue(key, out var list) && list.Count > 0 ? list[^1] : null; + + // ---- descriptors -------------------------------------------------------------------------- + + /// Validate every descriptor (sorted, deduplicated), then the set (one id once). + /// Returns the active extensions sorted by id, or null with the reasons. + public static List? Validate(IEnumerable paths, List errors) + { + var descriptors = new List(); + var failed = false; + foreach (var path in paths.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)) + { + var d = ReadDescriptor(path, errors); + failed |= d is null; + if (d is not null) + descriptors.Add(d); + } + foreach (var dup in descriptors.GroupBy(d => d.Id, StringComparer.Ordinal).Where(g => g.Count() > 1)) + { + errors.Add(new(dup.Last().Path, null, "error", "OWENB002", $"extension '{dup.Key}' is declared {dup.Count()} times")); + failed = true; + } + if (failed) + return null; + descriptors.Sort((a, b) => StringComparer.Ordinal.Compare(a.Id, b.Id)); + return descriptors; + } + + private static OwenDescriptor? ReadDescriptor(string path, List errors) + { + var before = errors.Count; + void Bad(string code, string text) => errors.Add(new(path, null, "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 || !System.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 = System.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 errors.Count > before ? null : new OwenDescriptor(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. + public 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(System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path))!); + File.WriteAllText(path, o.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + // ---- the input set ------------------------------------------------------------------------ + + /// The generator output directories the active extensions declare, in order. + public static IEnumerable Generators(IEnumerable descriptors) => + descriptors.SelectMany(d => d.Generators).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal); + + /// + /// What the extractor reads: the project, then the C# the declared generators wrote under + /// (the extractor's own expansion skips generated files, + /// and an extension's protocol lives in generated code). maps a + /// generator to the files an IDE holds for it in memory; a generator it names is read from + /// there instead of from the last build's output on disk. + /// + public static List Inputs(string project, string? generatedRoot, IReadOnlyList descriptors, + IReadOnlyDictionary>? live = null) + { + var inputs = new List { project }; + if (generatedRoot is null) + return inputs; + foreach (var generator in Generators(descriptors)) + { + if (live is not null && live.TryGetValue(generator, out var held)) + { + inputs.AddRange(held.Order(StringComparer.Ordinal)); + continue; + } + var dir = System.IO.Path.Combine(generatedRoot, generator); + if (Directory.Exists(dir)) + inputs.AddRange(Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories) + .Order(StringComparer.Ordinal)); + } + return inputs; + } + + // ---- refusals ----------------------------------------------------------------------------- + + // extractor: protocol lowering refused: OrderEndpoints.cs:17: + public static readonly Regex ExtractorRefusal = new(@"refused:\s+(?[^\r\n]+?):(?\d+):\s+(?.+)$"); + + // facts.json: error: + public static readonly Regex CoreRefusal = new(@"^.*?:\s+error:\s+(?.*\((?[^()\r\n]+):(?\d+)\).*)$"); + + /// One error per refusal line that names a location (its file made absolute + /// against the project directory); one project-level error carrying the whole output + /// when none does. Never zero. + public static List Refusals(string output, string project, string code, Regex located) + { + var dir = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(project))!; + var found = new List(); + foreach (var raw in output.Split('\n')) + { + var m = located.Match(raw.TrimEnd('\r')); + if (!m.Success) + continue; + var file = m.Groups["file"].Value; + found.Add(new(System.IO.Path.IsPathRooted(file) ? file : System.IO.Path.GetFullPath(System.IO.Path.Combine(dir, file)), + int.Parse(m.Groups["line"].Value, System.Globalization.CultureInfo.InvariantCulture), + "error", code, m.Groups["text"].Value)); + } + if (found.Count == 0) + found.Add(new(project, null, "error", code, OneLine(output))); + return found; + } + + // `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. + public static string Absolute(string text, string project) + { + var dir = System.IO.Path.GetDirectoryName(System.IO.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 && !System.IO.Path.IsPathRooted(m.Groups["file"].Value) + ? $"{System.IO.Path.GetFullPath(System.IO.Path.Combine(dir, m.Groups["file"].Value))}({m.Groups["pos"].Value}): {m.Groups["rest"].Value}" + : piece; + })); + } + + public static string OneLine(string text) => + string.Join(" ", text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim())); +} diff --git a/frontend/roslyn/OwnSharp.Cli/Program.cs b/frontend/roslyn/OwnSharp.Cli/Program.cs index 386b8537..2c0f73fe 100644 --- a/frontend/roslyn/OwnSharp.Cli/Program.cs +++ b/frontend/roslyn/OwnSharp.Cli/Program.cs @@ -38,6 +38,22 @@ } } +// The live Owen service for an IDE (OX-02, docs/notes/owen-visual-studio-preregistration.md): +// Owen.VisualStudio starts it from the Owen.Build package and speaks owen-live/1 to it. Not a +// user verb either. +if (args[0] == "serve") +{ + try + { + return await ServeCommand.RunAsync(args[1..]).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"owen serve: internal failure: {ex}"); + return 5; + } +} + if (args[0] != "check") { Console.Error.WriteLine($"owen: unknown command '{args[0]}'"); diff --git a/frontend/roslyn/OwnSharp.Cli/ServeCommand.cs b/frontend/roslyn/OwnSharp.Cli/ServeCommand.cs new file mode 100644 index 00000000..d686481a --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/ServeCommand.cs @@ -0,0 +1,310 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace OwnSharp.Cli; + +/// +/// `owen serve` — the live Owen service for an IDE (OX-02, +/// docs/notes/owen-visual-studio-preregistration.md §4–§5). Long-lived: started once by +/// Owen.VisualStudio, it answers analysis requests over its stdin/stdout until told to +/// shut down or until its stdin closes. Not a user verb. +/// +/// Protocol owen-live/1. Every message, both ways, is +/// Content-Length: n\r\n\r\n followed by n bytes of UTF-8 JSON. Nothing else is ever +/// written to stdout (the console is redirected to stderr before anything can print). The +/// first client message is hello naming this protocol. A malformed frame, a body that is +/// not a JSON object, an unknown type or another protocol is fatal: one fatal +/// frame, the reason on stderr, exit 3 — never a guess, never a resynchronisation. +/// +/// Order. Analyses run one at a time, in arrival order. A queued request whose key +/// has a newer queued request is answered superseded without running; cancel of a +/// queued request answers cancelled. A running analysis completes (the extractor is not +/// cancellable); the client publishes a result only for the latest version it issued. +/// +internal static class ServeCommand +{ + public const string Protocol = "owen-live/1"; + public const int ProtocolError = 3; + private const int MaxFrame = 256 * 1024 * 1024; + + private sealed record Pending(long Id, string Key, long Version, string Request, + IReadOnlyList Documents, IReadOnlyList Generated); + + private sealed class ProtocolException(string message) : Exception(message); + + public static async Task RunAsync(string[] args) + { + if (args.Length != 0) + { + Console.Error.WriteLine("usage: owen serve (started by an IDE; speaks owen-live/1 on stdin/stdout)"); + return 2; + } + var input = new BufferedStream(Console.OpenStandardInput()); + var output = Console.OpenStandardOutput(); + // stdout carries frames and nothing else: anything printed from here on goes to stderr + Console.SetOut(Console.Error); + var writeGate = new object(); + void Send(object message) + { + var body = JsonSerializer.SerializeToUtf8Bytes(message); + var header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n"); + lock (writeGate) + { + output.Write(header); + output.Write(body); + output.Flush(); + } + } + int Fatal(string reason) + { + Console.Error.WriteLine($"owen serve: fatal: {reason}"); + try { Send(new { type = "fatal", message = reason }); } catch (IOException) { /* the client is gone */ } + return ProtocolError; + } + + var pending = new List(); + var signal = new SemaphoreSlim(0); + var stop = new CancellationTokenSource(); + var worker = Task.Run(async () => + { + while (!stop.IsCancellationRequested) + { + try { await signal.WaitAsync(stop.Token).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + Pending? next; + lock (pending) + { + if (pending.Count == 0) + continue; + next = pending[0]; + pending.RemoveAt(0); + } + Send(await AnswerAsync(next).ConfigureAwait(false)); + // Each analysis builds a whole compilation (metadata for every reference); collect + // it now, between answers, rather than in the middle of the next one. + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + }); + + try + { + var hello = ReadFrame(input) ?? throw new ProtocolException("stdin closed before hello"); + if (Type(hello) != "hello" || Str(hello, "protocol") != Protocol) + throw new ProtocolException($"the first message must be {{\"type\":\"hello\",\"protocol\":\"{Protocol}\"}}"); + Send(new + { + type = "hello", + protocol = Protocol, + host = ToolVersion.Current, + capabilities = OwenHost.Capabilities, + }); + + while (true) + { + if (ReadFrame(input) is not { } message) + return 0; // the IDE closed our stdin: it is gone, so are we + switch (Type(message)) + { + case "analyze": + var request = new Pending( + Long(message, "id"), Str(message, "key"), Long(message, "version"), Str(message, "request"), + Documents(message, "documents"), Documents(message, "generated")); + lock (pending) + { + foreach (var older in pending.Where(p => p.Key == request.Key).ToList()) + { + pending.Remove(older); + Send(Answer(older, "superseded")); + } + pending.Add(request); + } + signal.Release(); + break; + case "cancel": + var id = Long(message, "id"); + lock (pending) + { + var queued = pending.FirstOrDefault(p => p.Id == id); + if (queued is not null) + { + pending.Remove(queued); + Send(Answer(queued, "cancelled")); + } + } + break; + case "shutdown": + return 0; + case var other: + throw new ProtocolException($"unknown message type '{other}'"); + } + } + } + catch (ProtocolException ex) + { + return Fatal(ex.Message); + } + finally + { + stop.Cancel(); + } + } + + private static object Answer(Pending p, string status) => new + { + type = "result", + id = p.Id, + key = p.Key, + version = p.Version, + status, + diagnostics = Array.Empty(), + extensions = Array.Empty(), + timing = new Dictionary(), + }; + + private static async Task AnswerAsync(Pending p) + { + var clock = Stopwatch.StartNew(); + try + { + var result = await LiveAnalysis.RunAsync(p.Request, p.Documents, p.Generated).ConfigureAwait(false); + return new + { + type = "result", + id = p.Id, + key = p.Key, + version = p.Version, + status = "ok", + diagnostics = result.Diagnostics.Select(d => new + { + code = d.Code, + severity = d.Severity, + message = d.Message, + file = d.File, + line = d.Line, + column = d.Column, + origin = d.Origin, + related = d.Related.Select(r => new { file = r.File, line = r.Line, column = r.Column, message = r.Message }), + }), + extensions = result.Extensions, + timing = result.TimingMs, + }; + } + catch (Exception ex) + { + // an analysis that blew up is answered, never dropped: the IDE shows it + Console.Error.WriteLine($"owen serve: analysis {p.Id} failed: {ex}"); + return new + { + type = "result", + id = p.Id, + key = p.Key, + version = p.Version, + status = "error", + diagnostics = new[] + { + new + { + code = "OWENB012", severity = "error", + message = $"the live analysis failed internally: {ex.GetType().Name}: {OwenHost.OneLine(ex.Message)}", + file = p.Key, line = (int?)null, column = (int?)null, origin = "host", + related = Array.Empty(), + }, + }, + extensions = Array.Empty(), + timing = new Dictionary { ["total"] = clock.ElapsedMilliseconds }, + }; + } + } + + // ---- frames --------------------------------------------------------------------------------- + + /// One frame, or null at a clean end of stream (between frames). + private static JsonElement? ReadFrame(Stream input) + { + int? length = null; + var sawAny = false; + while (true) + { + var line = ReadHeaderLine(input, ref sawAny); + if (line is null) + return sawAny ? throw new ProtocolException("stdin closed inside a frame header") : null; + if (line.Length == 0) + break; + const string prefix = "Content-Length: "; + if (!line.StartsWith(prefix, StringComparison.Ordinal) + || !int.TryParse(line.AsSpan(prefix.Length), System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out var n) + || n < 0 || n > MaxFrame || length is not null) + throw new ProtocolException($"malformed frame header '{line}'"); + length = n; + } + if (length is null) + throw new ProtocolException("a frame without Content-Length"); + var body = new byte[length.Value]; + var read = 0; + while (read < body.Length) + { + var got = input.Read(body, read, body.Length - read); + if (got == 0) + throw new ProtocolException($"stdin closed inside a {length} byte frame body"); + read += got; + } + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + throw new ProtocolException("a frame body that is not a JSON object"); + return doc.RootElement.Clone(); + } + catch (JsonException ex) + { + throw new ProtocolException($"a frame body that is not JSON: {ex.Message}"); + } + } + + private static string? ReadHeaderLine(Stream input, ref bool sawAny) + { + var bytes = new List(); + while (true) + { + var b = input.ReadByte(); + if (b < 0) + return bytes.Count == 0 && !sawAny ? null : throw new ProtocolException("stdin closed inside a frame header"); + sawAny = true; + if (b == '\n') + { + if (bytes.Count == 0 || bytes[^1] != '\r') + throw new ProtocolException("a frame header line not ended by CRLF"); + bytes.RemoveAt(bytes.Count - 1); + return Encoding.ASCII.GetString(bytes.ToArray()); + } + if (bytes.Count > 1024) + throw new ProtocolException("a frame header line longer than 1024 bytes"); + bytes.Add((byte)b); + } + } + + private static string Type(JsonElement m) => Str(m, "type"); + + private static string Str(JsonElement m, string name) => + m.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString()! + : throw new ProtocolException($"'{name}' must be a string"); + + private static long Long(JsonElement m, string name) => + m.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n) + ? n + : throw new ProtocolException($"'{name}' must be an integer"); + + private static List Documents(JsonElement m, string name) + { + if (!m.TryGetProperty(name, out var v)) + return []; + if (v.ValueKind != JsonValueKind.Array) + throw new ProtocolException($"'{name}' must be an array"); + return v.EnumerateArray().Select(d => new LiveDocument(Str(d, "path"), Str(d, "text"))).ToList(); + } +} diff --git a/frontend/roslyn/OwnSharp.Extractor/InProcess.cs b/frontend/roslyn/OwnSharp.Extractor/InProcess.cs new file mode 100644 index 00000000..0f49c8ae --- /dev/null +++ b/frontend/roslyn/OwnSharp.Extractor/InProcess.cs @@ -0,0 +1,93 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; + +// OX-02 (docs/notes/owen-visual-studio-preregistration.md): the extractor as a library. +// +// The extractor is ONE program; this file adds no second frontend. It lets a long-lived host +// (the `owen serve` IDE service) run that same program in its own process, with two +// differences from a command line and none in what is extracted: +// * the source of a path may come from an in-memory overlay (an IDE's unsaved buffer) +// instead of the disk — read at the one place the program reads a source file; +// * stdout/stderr are captured instead of inherited, so nothing the program prints can +// reach the host's own protocol stream. +// The arguments are the command line's arguments, so a run here and `ownsharp-extract` with +// the same arguments over the same file contents write the same facts, byte for byte +// (tests/check_extractor_in_process.py pins that over the extractor goldens). + +partial class Program +{ + /// Full path -> contents of an unsaved buffer, for the run in progress. + internal static IReadOnlyDictionary? SourceOverlay; + + internal static StringComparer PathComparer => + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + /// The overlaid text of , when an overlay holds it. No + /// filesystem access: the disk read stays at its one site in the parse loop. + static bool TryOverlay(string path, out string text) + { + text = ""; + if (SourceOverlay is null || !SourceOverlay.TryGetValue(Path.GetFullPath(path), out var held)) + return false; + text = held; + return true; + } +} + +namespace OwnSharp.Extractor +{ + /// Runs the extractor program inside the calling process. + public static class InProcessExtractor + { + // The program keeps run state in statics (it resets them at entry) and reads the + // process's current directory for the paths it reports: one run at a time. + private static readonly object Gate = new(); + + private static readonly MethodInfo Entry = + typeof(Program).Assembly.EntryPoint + ?? throw new InvalidOperationException("ownsharp-extract has no entry point"); + + /// + /// Run ownsharp-extract with (the command line's own + /// arguments) in . maps a + /// full source path to the text to read for it instead of the file. Returns the exit + /// code and everything the program printed. + /// + public static (int ExitCode, string Output) Run( + IReadOnlyList args, + string workingDirectory, + IReadOnlyDictionary? overlay) + { + lock (Gate) + { + var output = new StringWriter(); + var (stdout, stderr, cwd) = (Console.Out, Console.Error, Environment.CurrentDirectory); + Program.SourceOverlay = overlay is null + ? null + : new Dictionary( + overlay.Select(kv => KeyValuePair.Create(Path.GetFullPath(kv.Key), kv.Value)), + Program.PathComparer); + try + { + Console.SetOut(output); + Console.SetError(output); + Environment.CurrentDirectory = workingDirectory; + var rc = Entry.Invoke(null, [args.ToArray()]); + return ((int)rc!, output.ToString()); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + finally + { + Program.SourceOverlay = null; + Environment.CurrentDirectory = cwd; + Console.SetOut(stdout); + Console.SetError(stderr); + } + } + } + } +} diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 621f4bc6..f3b6cab6 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -111,6 +111,11 @@ // as belonging to a Fody-enabled project must not stay "weaver-owned" for an unrelated later // invocation in the same process (a sticky false positive). Same reset discipline as above. WeaverOwnedFiles.Clear(); +// OX-02: the two collectors below are filled while a run walks the compilation, so a run in a +// long-lived process (InProcessExtractor, the IDE service) must start them empty too. A fresh +// process starts them empty anyway: for the CLI this is a no-op. +GuardedFactsViolations.Clear(); +OrphanedAwaitables.Sites.Clear(); // `extract` verb: the tool's one job is extraction, so an optional leading `extract` // makes the advertised `ownsharp-extract extract --project App.csproj --out facts.json` // UX real while the bare form (no verb) stays the default and back-compatible. (The @@ -5698,21 +5703,26 @@ static bool IsPublicCtor(SyntaxTokenList modifiers) // Defensive: an explicit input that is not a readable file (a directory // passed by mistake, a deleted path) is skipped with a note, never an // unhandled exception that aborts the whole scan. - if (!File.Exists(path)) + // OX-02: an IDE's unsaved buffer for this path is read INSTEAD of the file + // (InProcessExtractor). The CLI never has an overlay, so for it this is the disk path + // below, unchanged. + if (!TryOverlay(path, out var text)) { - Console.Error.WriteLine($"ownsharp-extract: skipping (not a file): {path}"); - continue; - } - string text; - try - { - text = File.ReadAllText(path); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // A locked/unreadable file is skipped with a note, not an abort. - Console.Error.WriteLine($"ownsharp-extract: skipping unreadable file: {path} ({ex.Message})"); - continue; + if (!File.Exists(path)) + { + Console.Error.WriteLine($"ownsharp-extract: skipping (not a file): {path}"); + continue; + } + try + { + text = File.ReadAllText(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A locked/unreadable file is skipped with a note, not an abort. + Console.Error.WriteLine($"ownsharp-extract: skipping unreadable file: {path} ({ex.Message})"); + continue; + } } parsed.Add((Rel(path), CSharpSyntaxTree.ParseText(text, path: path))); if (path.EndsWith(".xaml.cs", StringComparison.OrdinalIgnoreCase)) diff --git a/scripts/owen_live_gate.py b/scripts/owen_live_gate.py new file mode 100644 index 00000000..b8be0adc --- /dev/null +++ b/scripts/owen_live_gate.py @@ -0,0 +1,608 @@ +"""OX-02 live gate: the Owen IDE service, as Owen.VisualStudio uses it, outside any IDE. + +docs/notes/owen-visual-studio-preregistration.md. Packs Owen.Build (with this platform's Rust +core), Owen.TypedBuilder and two synthetic extensions, creates a project OUTSIDE the checkout +that references Owen.TypedBuilder, and then — with no build of that project — proves: + + L0 a design-time build (what Visual Studio runs on load) writes obj/owen/live.txt naming + the package's service, and analyses nothing; + S* `owen serve` speaks owen-live/1 and nothing else on stdout; malformed input is fatal (3); + K1 an unsaved clean edit -> no OWN finding; K2 an unsaved second Submit -> OWN002 + K3 the line deleted -> gone; K5 broken syntax -> an answer, then recovery + K4s queued requests of one project: only the newest runs (superseded), cancel answers cancelled + K7 an extension needing an unknown capability -> OWENB004, live + K8 a second extension with its own generator -> its finding too, no host change + P10 build/live parity on saved sources (code, severity, message, file, line) + T latency (warm p50/p95, cold start) and the service's working set after 10 and 100 runs + C tests/owen-live/LiveClientTests against the real service (K4, K6, M1/M2 controls) + G neither the service nor Owen.VisualStudio names an extension + +Run: python scripts/owen_live_gate.py [--rust-core ] [--keep] +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import statistics +import subprocess +import sys +import tempfile +import time +from typing import Any + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import owen_extension_gate as ox + +ROOT = ox.ROOT +LIVE = os.path.join(ROOT, "tests", "owen-live") +FIXTURE = os.path.join(LIVE, "LiveFixture") +TEST_PROTOCOL = os.path.join(ROOT, "tests", "owen-extensions", "Owen.TestProtocol") +GENERIC_SOURCES = ( + os.path.join(ox.ROSLYN, "OwnSharp.Cli", "ServeCommand.cs"), + os.path.join(ox.ROSLYN, "OwnSharp.Cli", "LiveAnalysis.cs"), + os.path.join(ox.ROSLYN, "OwnSharp.Cli", "OwenHost.cs"), + os.path.join(ox.ROSLYN, "Owen.VisualStudio"), +) +CANONICAL = re.compile( + r"^(?P.+?)\((?P\d+)(?:,\d+)?\): (?Pwarning|error) " + r"(?POW(?:EN)?[NB]?\d+): " + r"(?P.*?)(?: \[[^\]]*\.csproj\])?$" +) + +check = ox.check + +USE_CLEAN = open(os.path.join(FIXTURE, "Use.cs.txt"), encoding="utf-8").read() +ANCHOR = " var submitted = draft.Submit(now);\n" +STALE_LINE = " draft.Submit(now);\n" +USE_STALE = USE_CLEAN.replace(ANCHOR, ANCHOR + STALE_LINE) +USE_BROKEN = USE_CLEAN.replace(ANCHOR, ANCHOR + " draft.Submit(now\n") +USE_COPY = USE_CLEAN.replace( + ANCHOR, " var copy = draft;\n" + ANCHOR + " copy.Submit(now);\n" +) +USE_RAW = USE_CLEAN.replace(ANCHOR, ANCHOR + " var customer = order.Customer;\n") +USE_REFUSED = USE_CLEAN.replace(ANCHOR, ANCHOR + ' System.Console.WriteLine("x");\n') +GATE_CLEAN = open(os.path.join(FIXTURE, "Gate.cs.txt"), encoding="utf-8").read() +GATE_STALE = GATE_CLEAN.replace( + " var unlocked = locked.Coin();\n", + " var unlocked = locked.Coin();\n locked.Coin();\n", +) + + +# ---- the service, as a client sees it --------------------------------------------------------- + + +class Service: + def __init__(self, host: str, env: dict[str, str], cwd: str) -> None: + env = dict(env, DOTNET_ROLL_FORWARD="Major") + self.proc = subprocess.Popen( + ["dotnet", "exec", host, "serve"], + cwd=cwd, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.next_id = 0 + + def send(self, message: dict[str, Any]) -> None: + body = json.dumps(message).encode("utf-8") + assert self.proc.stdin is not None + self.proc.stdin.write(b"Content-Length: %d\r\n\r\n" % len(body) + body) + self.proc.stdin.flush() + + def read(self) -> dict[str, Any] | None: + assert self.proc.stdout is not None + length = None + while True: + line = self.proc.stdout.readline() + if not line: + return None + if line == b"\r\n": + break + m = re.fullmatch(rb"Content-Length: (\d+)\r\n", line) + if m is None: + raise AssertionError(f"not a frame header on the service's stdout: {line!r}") + length = int(m.group(1)) + assert length is not None + body = self.proc.stdout.read(length) + result: dict[str, Any] = json.loads(body) + return result + + def hello(self) -> dict[str, Any] | None: + self.send({"type": "hello", "protocol": "owen-live/1"}) + return self.read() + + def submit( + self, + key: str, + version: int, + request: str, + documents: list[tuple[str, str]], + generated: list[tuple[str, str]], + ) -> int: + self.next_id += 1 + self.send( + { + "type": "analyze", + "id": self.next_id, + "key": key, + "version": version, + "request": request, + "documents": [{"path": p, "text": t} for p, t in documents], + "generated": [{"path": p, "text": t} for p, t in generated], + } + ) + return self.next_id + + def analyze( + self, + key: str, + version: int, + request: str, + documents: list[tuple[str, str]], + generated: list[tuple[str, str]], + ) -> dict[str, Any]: + self.submit(key, version, request, documents, generated) + answer = self.read() + assert answer is not None, "the service closed its stdout" + return answer + + def close(self) -> tuple[int, str]: + try: + self.send({"type": "shutdown"}) + except OSError: + pass + try: + _, err = self.proc.communicate(timeout=30) + except subprocess.TimeoutExpired: + self.proc.kill() + _, err = self.proc.communicate() + return self.proc.returncode, err.decode("utf-8", "replace") + + +def raw_exchange( + host: str, env: dict[str, str], cwd: str, payload: bytes +) -> tuple[int, bytes, str]: + done = subprocess.run( + ["dotnet", "exec", host, "serve"], + cwd=cwd, + env=dict(env, DOTNET_ROLL_FORWARD="Major"), + input=payload, + capture_output=True, + timeout=120, + check=False, + ) + return done.returncode, done.stdout, done.stderr.decode("utf-8", "replace") + + +def frame(message: dict[str, Any]) -> bytes: + body = json.dumps(message).encode("utf-8") + return b"Content-Length: %d\r\n\r\n" % len(body) + body + + +def frames(data: bytes) -> list[dict[str, Any]] | None: + """Every byte of stdout as frames, or None if anything else is there.""" + out = [] + while data: + m = re.match(rb"Content-Length: (\d+)\r\n\r\n", data) + if m is None: + return None + start = m.end() + end = start + int(m.group(1)) + out.append(json.loads(data[start:end])) + data = data[end:] + return out + + +def codes(answer: dict[str, Any]) -> list[str]: + return sorted(d["code"] for d in answer["diagnostics"]) + + +# ---- the fixture ------------------------------------------------------------------------------ + + +def scaffold(c: ox.Consumer, name: str) -> None: + c.dir = os.path.join(c.ws, name) + os.makedirs(c.dir) + done = c.dotnet("new", "classlib", "-n", name, "-o", ".", "--framework", "net8.0") + if done.returncode != 0: + raise SystemExit(f"FAIL: dotnet new classlib: {done.stdout[-800:]}{done.stderr[-800:]}") + os.remove(c.path("Class1.cs")) + done = c.dotnet("add", "package", "Owen.TypedBuilder", "--version", ox.VERSION) + if done.returncode != 0: + raise SystemExit(f"FAIL: dotnet add package: {done.stdout[-1500:]}") + write(c.path("Order.cs"), open(os.path.join(FIXTURE, "Order.cs.txt"), encoding="utf-8").read()) + write(c.path("Use.cs"), USE_CLEAN) + + +def write(path: str, text: str) -> None: + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + + +def design_time(c: ox.Consumer) -> subprocess.CompletedProcess[str]: + done = c.dotnet("restore", "-nologo") + if done.returncode != 0: + raise SystemExit(f"FAIL: restore: {done.stdout[-1500:]}") + return c.dotnet( + "msbuild", + "-nologo", + "-t:CoreCompile", + "-p:DesignTimeBuild=true", + "-p:SkipCompilerExecution=true", + "-p:ProvideCommandLineArgs=true", + "-p:BuildingProject=false", + ) + + +def read_request(path: str) -> dict[str, list[str]]: + out: dict[str, list[str]] = {} + with open(path, encoding="utf-8") as f: + for line in f: + key, _, value = line.rstrip("\r\n").partition("\t") + if value: + out.setdefault(key, []).append(value) + return out + + +def generated_like_an_ide( + c: ox.Consumer, packages: list[str], extra: dict[str, str] +) -> list[tuple[str, str]]: + """What Visual Studio's workspace holds as source-generated documents, WITHOUT building the + project: build a throwaway copy, and name each output /// + — a base of the IDE's own, not obj/generated, so the service has to relocate it.""" + copy = ox.Consumer.__new__(ox.Consumer) + copy.__dict__.update(c.__dict__) + copy.dir = os.path.join(c.ws, "GenCopy-" + str(len(packages))) + shutil.copytree(c.dir, copy.dir, ignore=shutil.ignore_patterns("bin", "obj")) + for rel, text in extra.items(): + write(os.path.join(copy.dir, rel), text) + done = copy.build("-p:OwenEnabled=false", "-p:EmitCompilerGeneratedFiles=true") + if done.returncode != 0: + raise SystemExit(f"FAIL: building the generator copy: {done.stdout[-1500:]}") + root = os.path.join(copy.dir, "obj", "Debug", "net8.0", "generated") + if not os.path.isdir(root): + root = os.path.join(copy.dir, "obj", "generated") + out = [] + for dirpath, _, files in os.walk(root): + for name in sorted(files): + full = os.path.join(dirpath, name) + rel = os.path.relpath(full, root) + with open(full, encoding="utf-8") as f: + out.append((os.path.join(c.dir, rel), f.read())) + return sorted(out) + + +def build_lines(c: ox.Consumer) -> tuple[int, set[tuple[str, int, str, str, str]]]: + done = c.build() + found = set() + for raw in (done.stdout + done.stderr).splitlines(): + m = CANONICAL.match(raw.strip()) + if m and m.group("code").startswith(("OWN", "OWENB")): + found.add( + ( + os.path.normcase(os.path.realpath(m.group("file"))), + int(m.group("line")), + m.group("sev"), + m.group("code"), + m.group("msg"), + ) + ) + return done.returncode, found + + +def live_set(answer: dict[str, Any]) -> set[tuple[str, int, str, str, str]]: + return { + ( + os.path.normcase(os.path.realpath(d["file"])), + int(d["line"] or 0), + d["severity"], + d["code"], + d["message"], + ) + for d in answer["diagnostics"] + } + + +# ---- the gate --------------------------------------------------------------------------------- + + +def generic() -> None: + hits = [] + for src in GENERIC_SOURCES: + paths = ( + [src] + if os.path.isfile(src) + else [ + os.path.join(d, n) + for d, _, fs in os.walk(src) + for n in fs + if n.endswith((".cs", ".csproj", ".vsixmanifest")) + and os.sep + "obj" + os.sep not in d + and os.sep + "bin" + os.sep not in d + ] + ) + for path in paths: + with open(path, encoding="utf-8", errors="replace") as f: + text = f.read() + if "TypedBuilder" in text or "TestProtocol" in text or "Turnstile" in text: + hits.append(os.path.relpath(path, ROOT)) + check("G-host-is-generic", hits == [], f"service/VSIX sources naming an extension: {hits}") + + +def protocol_errors(host: str, env: dict[str, str], cwd: str) -> None: + for name, payload in ( + ("S-bad-protocol", b'Content-Length: 41\r\n\r\n{"type":"hello","protocol":"owen-live/9"}'), + ("S-bad-header", b"Content-Lenght: 2\r\n\r\n{}"), + ("S-not-json", b"Content-Length: 5\r\n\r\nhello"), + ( + "S-unknown-type", + frame({"type": "hello", "protocol": "owen-live/1"}) + frame({"type": "poke"}), + ), + ): + rc, out, err = raw_exchange(host, env, cwd, payload) + parsed = frames(out) + fatal = parsed is not None and parsed and parsed[-1].get("type") == "fatal" + check( + name, + rc == 3 and bool(fatal) and "fatal" in err, + f"exit {rc}, stdout frames {[p.get('type') for p in parsed or []]}, " + f"stderr {err.strip()[:120]!r}", + ) + + +def main() -> int: + argv = sys.argv[1:] + given = argv[argv.index("--rust-core") + 1] if "--rust-core" in argv else None + keep = "--keep" in argv + work = tempfile.mkdtemp(prefix="owen-live-gate-") + try: + run_gate(work, given) + finally: + if keep: + print(f"kept: {work}") + else: + shutil.rmtree(work, ignore_errors=True) + print(f"owen live gate ({ox.KEY}): {len(ox.PASSED)} checks passed, {len(ox.FAILS)} failed") + for f in ox.FAILS: + print(f"FAIL: {f}") + return 1 if ox.FAILS else 0 + + +def run_gate(work: str, given: str | None) -> None: + generic() + stage = ox.stage_core(work, given) + feed = ox.pack(work, stage) + done = ox.run( + ["dotnet", "pack", TEST_PROTOCOL, "-c", "Release", "-o", feed, "-nologo"], cwd=ROOT + ) + if done.returncode != 0: + raise SystemExit(f"FAIL: pack Owen.TestProtocol: {done.stdout[-1500:]}") + c = ox.Consumer(work, feed) + ox.isolation(c) + scaffold(c, "LiveFixture") + project = c.path("LiveFixture.csproj") + use = c.path("Use.cs") + + # ---- L0: the live request, from a design-time build only -------------------------------- + dt = design_time(c) + live_txt = c.path("obj", "owen", "live.txt") + request = read_request(live_txt) if os.path.exists(live_txt) else {} + host = (request.get("host") or [""])[-1] + check( + "L0-live-request", + dt.returncode == 0 + and bool(host) + and os.path.isfile(host) + and os.path.realpath(host).startswith(os.path.realpath(c.nuget)) + and len(request.get("descriptor", [])) == 1, + f"design-time build exit {dt.returncode}; live.txt keys {sorted(request)}; host {host}", + ) + built = [n for _, _, fs in os.walk(c.path("bin")) for n in fs] + check( + "L0-no-build", + built == [] + and not os.path.exists(c.path("obj", "owen", "request.txt")) + and not os.path.exists(c.path("obj", "owen", "extensions.json")), + f"no bin/ output ({built}), no build-check request, no manifest: nothing was analysed", + ) + + generated = generated_like_an_ide(c, ["Owen.TypedBuilder"], {}) + check( + "L0-generated-in-memory", + len(generated) >= 2 and not os.path.exists(c.path("obj", "Debug", "net8.0", "generated")), + f"{len(generated)} generated documents held in memory, none on disk: " + f"{[os.path.relpath(p, c.dir) for p, _ in generated]}", + ) + + # ---- the service ------------------------------------------------------------------------ + protocol_errors(host, c.env, c.dir) + t0 = time.perf_counter() + svc = Service(host, c.env, c.dir) + hello = svc.hello() + check( + "S-hello", hello is not None and hello.get("protocol") == "owen-live/1", f"hello: {hello}" + ) + first = svc.analyze(project, 1, live_txt, [(use, USE_CLEAN)], generated) + cold = (time.perf_counter() - t0) * 1000 + check( + "K1-clean", + first["status"] == "ok" and not [x for x in codes(first) if x.startswith("OWN")], + f"unsaved clean text: status {first['status']}, codes {codes(first)}, " + f"extensions {first['extensions']}", + ) + + k2 = svc.analyze(project, 2, live_txt, [(use, USE_STALE)], generated) + own002 = [d for d in k2["diagnostics"] if d["code"] == "OWN002"] + stale_line = USE_STALE.splitlines().index(STALE_LINE.rstrip("\n")) + 1 + region_line = ( + USE_STALE.splitlines().index(" OrderProtocol.WithDraft(order, draft =>") + 1 + ) + with open(use, encoding="utf-8") as f: + on_disk = f.read() + check( + "K2-unsaved-own002", + len(own002) == 1 + and os.path.realpath(own002[0]["file"]) == os.path.realpath(use) + and own002[0]["line"] == region_line + and any(r["line"] == stale_line for r in own002[0]["related"]) + and on_disk == USE_CLEAN, + f"disk still clean={on_disk == USE_CLEAN}; OWN002 at line {[d['line'] for d in own002]} " + f"(region entry {region_line}), witness lines " + f"{[r['line'] for d in own002 for r in d['related']]} (inserted line {stale_line}); " + f"message {[d['message'] for d in own002]}", + ) + + k3 = svc.analyze(project, 3, live_txt, [(use, USE_CLEAN)], generated) + check("K3-deleted-gone", "OWN002" not in codes(k3), f"line deleted again: codes {codes(k3)}") + + k5 = svc.analyze(project, 4, live_txt, [(use, USE_BROKEN)], generated) + k5b = svc.analyze(project, 5, live_txt, [(use, USE_STALE)], generated) + check( + "K5-broken-syntax", + k5["status"] == "ok" and "OWN002" in codes(k5b), + f"broken edit answered {k5['status']} with {codes(k5)}; " + f"the next edit recovered: {codes(k5b)}", + ) + + # K4 on the service: three queued requests of one project, the newest wins + ids = [svc.submit(project, v, live_txt, [(use, USE_STALE)], generated) for v in (6, 7, 8)] + cancel_id = svc.submit(project + ".other", 1, live_txt, [(use, USE_CLEAN)], generated) + svc.send({"type": "cancel", "id": cancel_id}) + answers: dict[int, dict[str, Any]] = {} + while len(answers) < 4: + got = svc.read() + assert got is not None + answers[got["id"]] = got + statuses = [answers[i]["status"] for i in ids] + check( + "K4s-newest-runs", + statuses[-1] == "ok" + and set(statuses[:-1]) <= {"ok", "superseded"} + and "superseded" in statuses + and answers[cancel_id]["status"] in ("cancelled", "ok"), + f"versions 6,7,8 -> {statuses}; cancelled request -> {answers[cancel_id]['status']}", + ) + + # latency and footprint + times = [] + sets = [] + phases = [] + for i in range(100): + start = time.perf_counter() + a = svc.analyze( + project, 100 + i, live_txt, [(use, USE_STALE if i % 2 else USE_CLEAN)], generated + ) + times.append((time.perf_counter() - start) * 1000) + phases.append((a["timing"].get("extract", 0), a["timing"].get("core", 0))) + if i in (9, 99): + sets.append(a["timing"].get("working_set_kb", 0)) + warm = sorted(times[5:]) + p50 = statistics.median(warm) + p95 = warm[int(len(warm) * 0.95) - 1] + check( + "T-warm-latency", + p95 < 750, + f"warm analysis round trip p50 {p50:.0f} ms, p95 {p95:.0f} ms " + f"(threshold 750; in the service: " + f"extract median {statistics.median(x for x, _ in phases[5:]):.0f} ms, core median " + f"{statistics.median(y for _, y in phases[5:]):.0f} ms); cold start " + f"(spawn + hello + first analysis) {cold:.0f} ms", + ) + check( + "T-memory", + sets[1] < 2 * sets[0], + f"working set after 10 analyses {sets[0] // 1024} MB, after 100 {sets[1] // 1024} MB", + ) + + # ---- K7: an extension that needs what this host lacks ----------------------------------- + descriptor = request["descriptor"][0] + with open(descriptor, "rb") as f: + original = f.read() + try: + doc = json.loads(original) + doc["requires"]["capabilities"].append("telepathy") + with open(descriptor, "w", encoding="utf-8") as f: + json.dump(doc, f) + k7 = svc.analyze(project, 300, live_txt, [(use, USE_STALE)], generated) + check( + "K7-unknown-capability", + codes(k7) == ["OWENB004"] and "telepathy" in k7["diagnostics"][0]["message"], + f"live answer: {[(d['code'], d['message'][:90]) for d in k7['diagnostics']]}", + ) + finally: + with open(descriptor, "wb") as f: + f.write(original) + + # ---- K8: a second extension with its own generator ---------------------------------------- + done = c.dotnet("add", "package", "Owen.TestProtocol", "--version", ox.VERSION) + if done.returncode != 0: + raise SystemExit(f"FAIL: add Owen.TestProtocol: {done.stdout[-1500:]}") + write(c.path("Gate.cs"), GATE_CLEAN) + dt = design_time(c) + request = read_request(live_txt) + generated2 = generated_like_an_ide(c, ["Owen.TypedBuilder", "Owen.TestProtocol"], {}) + gate = c.path("Gate.cs") + k8 = svc.analyze(project, 400, live_txt, [(use, USE_STALE), (gate, GATE_STALE)], generated2) + files = sorted(os.path.basename(d["file"]) for d in k8["diagnostics"] if d["code"] == "OWN002") + check( + "K8-two-extensions", + len(request.get("descriptor", [])) == 2 + and sorted(k8["extensions"]) == ["Owen.TestProtocol", "Owen.TypedBuilder"] + and files == ["Gate.cs", "Use.cs"], + f"descriptors {len(request.get('descriptor', []))}; active {k8['extensions']}; " + f"OWN002 in {files}", + ) + + rc, err = svc.close() + check("S-shutdown", rc == 0, f"shutdown exit {rc}; stderr {err.strip()[-200:]!r}") + + # ---- P10: build/live parity on saved sources --------------------------------------------- + write(gate, GATE_CLEAN) + svc = Service(host, c.env, c.dir) + svc.hello() + for name, text in ( + ("clean", USE_CLEAN), + ("stale", USE_STALE), + ("copy", USE_COPY), + ("raw", USE_RAW), + ("refused", USE_REFUSED), + ): + write(use, text) + rc, built_set = build_lines(c) + live = svc.analyze(project, 500, live_txt, [], []) + live_found = live_set(live) + check( + f"P10-parity-{name}", + built_set == live_found and (name == "clean" or bool(built_set)), + f"build {sorted(x[3:] for x in built_set)} vs live {sorted(x[3:] for x in live_found)}" + + ( + "" + if built_set == live_found + else f"; build-only {built_set - live_found}; live-only {live_found - built_set}" + ), + ) + svc.close() + write(use, USE_CLEAN) + + # ---- C: the client library against the real service -------------------------------------- + tests = os.path.join(LIVE, "LiveClientTests") + done = ox.run(["dotnet", "build", tests, "-c", "Release", "-nologo", "-v", "q"], cwd=ROOT) + if done.returncode != 0: + raise SystemExit(f"FAIL: LiveClientTests build: {done.stdout[-1500:]}") + dll = os.path.join(tests, "bin", "Release", "net8.0", "LiveClientTests.dll") + done = ox.run(["dotnet", dll, host, live_txt], cwd=c.dir, env=c.env) + print(done.stdout, end="") + last = done.stdout.strip().splitlines()[-1] if done.stdout.strip() else done.stderr[-300:] + check( + "C-client-tests", + done.returncode == 0 and "0 failed" in done.stdout, + f"LiveClientTests exit {done.returncode}: {last}", + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/owen_live_mutations.py b/scripts/owen_live_mutations.py new file mode 100644 index 00000000..9783e690 --- /dev/null +++ b/scripts/owen_live_mutations.py @@ -0,0 +1,163 @@ +"""OX-02 mutation controls M1-M6 (docs/notes/owen-visual-studio-preregistration.md §12). + +Each mutation edits ONE product source the way the bug it stands for would, runs the check +that is registered to catch it, and requires that check to go red; the source is restored +whatever happens. A mutation that leaves its check green is a FAIL of this script. + + M1 the client publishes every answer, current or not -> LiveClientTests K4-stale-dropped + M2 a service that cannot start is swallowed silently -> LiveClientTests M2-cannot-start + M3 the coordinate contract is off by one -> LiveClientTests coord-* + M4 the host routes only Typed Builder's generated sources -> owen_live_gate K8-two-extensions + M5 live forms its own severity instead of the core's -> owen_live_gate P10-parity-* + M6 the unsaved document is read from disk -> owen_live_gate K2 + extractor E4 + +Run: python scripts/owen_live_mutations.py [--rust-core ] [--only M1,M3] +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile + +ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +VS = os.path.join(ROOT, "frontend", "roslyn", "Owen.VisualStudio", "Live") +CLI = os.path.join(ROOT, "frontend", "roslyn", "OwnSharp.Cli") +EXTRACTOR = os.path.join(ROOT, "frontend", "roslyn", "OwnSharp.Extractor") +TESTS = os.path.join(ROOT, "tests", "owen-live", "LiveClientTests") + +MUTATIONS: list[tuple[str, str, str, str, str, list[str]]] = [ + ( + "M1", + os.path.join(VS, "LiveEngine.cs"), + "if (!IgnoreVersions && !_gate.IsCurrent(snapshot.Key, response.Version))", + "if (false && !_gate.IsCurrent(snapshot.Key, response.Version))", + "client", + ["K4-stale-dropped"], + ), + ( + "M2", + os.path.join(VS, "LiveEngine.cs"), + " if (IgnoreVersions || _gate.IsCurrent(snapshot.Key, version))\n" + " Publish(", + " if (false)\n Publish(", + "client", + ["M2-cannot-start"], + ), + ( + "M3", + os.path.join(VS, "LiveScheduler.cs"), + "return (start, end, start + 1);", + "return (start + 1, end, start + 2);", + "client", + ["coord-no-column", "coord-column"], + ), + ( + "M4", + os.path.join(CLI, "OwenHost.cs"), + "descriptors.SelectMany(d => d.Generators)", + 'descriptors.Where(d => d.Id == "Owen.TypedBuilder").SelectMany(d => d.Generators)', + "gate", + ["K8-two-extensions"], + ), + ( + "M5", + os.path.join(CLI, "LiveAnalysis.cs"), + 'level == "error" ? "error" : "warning",', + '"error",', + "gate", + ["P10-parity-stale", "P10-parity-copy", "P10-parity-raw"], + ), + ( + "M6", + os.path.join(EXTRACTOR, "InProcess.cs"), + "if (SourceOverlay is null || !SourceOverlay.TryGetValue(", + "if (true || !SourceOverlay!.TryGetValue(", + "gate", + ["K2-unsaved-own002"], + ), +] + + +def run(cmd: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + + +def red(output: str, names: list[str]) -> list[str]: + return [n for n in names if f"FAIL[{n}]" in output] + + +def main() -> int: + argv = sys.argv[1:] + core = argv[argv.index("--rust-core") + 1] if "--rust-core" in argv else None + only = set(argv[argv.index("--only") + 1].split(",")) if "--only" in argv else None + failures = [] + # the client tests' service half needs a real `owen serve` and a live request; a request + # with no extension is enough (the service answers OWENB001), so build the host locally + built = run(["dotnet", "build", CLI, "-nologo", "-v", "q"]) + if built.returncode != 0: + raise SystemExit(f"FAIL: OwnSharp.Cli does not build: {built.stdout[-800:]}") + host = os.path.join(CLI, "bin", "Debug", "net8.0", "ownsharp.dll") + scratch = tempfile.mkdtemp(prefix="owen-live-mutations-") + request = os.path.join(scratch, "live.txt") + with open(request, "w", encoding="utf-8") as f: + f.write(f"project\t{os.path.join(scratch, 'Empty.csproj')}\nhost\t{host}\n") + for name, path, before, after, where, expect in MUTATIONS: + if only is not None and name not in only: + continue + with open(path, encoding="utf-8") as f: + original = f.read() + if original.count(before) != 1: + failures.append( + f"{name}: its anchor is not exactly once in {os.path.relpath(path, ROOT)}" + ) + continue + try: + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(original.replace(before, after)) + if where == "client": + built = run(["dotnet", "build", TESTS, "-c", "Release", "-nologo", "-v", "q"]) + if built.returncode != 0: + failures.append( + f"{name}: LiveClientTests no longer builds: {built.stdout[-500:]}" + ) + continue + done = run( + [ + "dotnet", + os.path.join(TESTS, "bin", "Release", "net8.0", "LiveClientTests.dll"), + host, + request, + ] + ) + else: + cmd = [sys.executable, os.path.join(ROOT, "scripts", "owen_live_gate.py")] + if core: + cmd += ["--rust-core", core] + done = run(cmd) + caught = red(done.stdout, expect) + if caught: + print(f"ok[{name}]: caught by {caught} (exit {done.returncode})") + else: + failures.append(f"{name}: none of {expect} went red (exit {done.returncode})") + print(done.stdout[-3000:]) + finally: + with open(path, "w", encoding="utf-8", newline="") as f: + f.write(original) + for failure in failures: + print(f"FAIL[{failure.split(':')[0]}]: {failure}") + print(f"owen live mutations: {len(failures)} not caught") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/vs/owen-vs-acceptance.ps1 b/scripts/vs/owen-vs-acceptance.ps1 new file mode 100644 index 00000000..bbbcd13a --- /dev/null +++ b/scripts/vs/owen-vs-acceptance.ps1 @@ -0,0 +1,424 @@ +# OX-02 P14: the real Visual Studio acceptance of Owen.VisualStudio +# (docs/notes/owen-visual-studio-preregistration.md §7, §8, §10). +# +# Installs the built VSIX into a dedicated root suffix, opens the prepared solution (restored, +# never built), and drives the IDE the way a user would: open Use.cs, type a second +# `draft.Submit(now);` WITHOUT saving, and require +# * Owen's squiggle (the tagger's own tags, from the opt-in trace) on the inserted line and at +# the finding's location, +# * an OWN002 row in the Error List (read from the Error List control itself, UI Automation), +# * double-clicking that row moves the caret to the row's file/line/column, +# * a CS error and OWN002 side by side, +# * deleting the line makes the row and the squiggles disappear, +# and measures edit -> squiggle and edit -> disappearance over repeated edits, and the longest +# UI-thread handler. Nothing is saved and nothing is built. +# +# ADAPTED from Snipper (PhysShell/snipper 43b395a, MIT, extensions/snipper-vs/scripts/vs-smoke.ps1): +# vswhere instance selection, a dedicated root suffix (never the shared Exp hive), the activity +# log, and the relaunch when VS restarts itself after registering an extension. +param( + [Parameter(Mandatory = $true)][string]$Vsix, + [Parameter(Mandatory = $true)][string]$Fixture, + [Parameter(Mandatory = $true)][string]$Out, + [string]$RootSuffix = "OwenLive", + [int]$Repeats = 10 +) + +$ErrorActionPreference = 'Stop' +New-Item -ItemType Directory -Force $Out | Out-Null +$results = [ordered]@{ checks = @(); measurements = [ordered]@{} } +$failed = $false +function Log($m) { $line = "[$(Get-Date -Format HH:mm:ss.fff)] $m"; Write-Host $line; Add-Content (Join-Path $Out 'acceptance.log') $line } +function Check($name, $holds, $detail) { + $script:results.checks += [ordered]@{ name = $name; ok = [bool]$holds; detail = "$detail" } + if ($holds) { Log "ok[$name]: $detail" } else { Log "FAIL[$name]: $detail"; $script:failed = $true } +} +function Now() { [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() } + +Add-Type -AssemblyName System.Windows.Forms, System.Drawing, UIAutomationClient, UIAutomationTypes +Add-Type @" +using System; +using System.Runtime.InteropServices; +public static class Mouse { + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, UIntPtr e); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + public static void DoubleClick(int x, int y) { + SetCursorPos(x, y); + for (int i = 0; i < 2; i++) { mouse_event(2, 0, 0, 0, UIntPtr.Zero); mouse_event(4, 0, 0, 0, UIntPtr.Zero); } + } +} +"@ + +function Shot($name) { + try { + $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size) + $bmp.Save((Join-Path $Out "$name.png")); $g.Dispose(); $bmp.Dispose() + } catch { Log "screenshot failed: $_" } +} + +function Retry([scriptblock]$Block, [int]$Seconds = 60) { + $deadline = (Get-Date).AddSeconds($Seconds) + while ($true) { + try { return & $Block } + catch { + if ((Get-Date) -gt $deadline) { throw } + Start-Sleep -Milliseconds 500 + } + } +} + +# Where an installed extension and the MEF composition's own errors live, for the evidence. +function Collect-Diagnostics { + $hive = Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Microsoft\VisualStudio') -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "*$RootSuffix" } + foreach ($h in $hive) { + Log "hive: $($h.FullName)" + Get-ChildItem (Join-Path $h.FullName 'Extensions') -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like '*Owen*' -or $_.Name -like '*.vsixmanifest' -or $_.Name -like 'extensions.*' } | + ForEach-Object { Log " ext: $($_.FullName)" } + Get-ChildItem (Join-Path $h.FullName 'ComponentModelCache') -ErrorAction SilentlyContinue | ForEach-Object { + Log " cache: $($_.Name) $($_.Length)" + if ($_.Name -like '*.err') { Copy-Item $_.FullName (Join-Path $Out $_.Name) -ErrorAction SilentlyContinue } + } + } + Get-ChildItem $env:TEMP -Filter 'dd_VSIXInstaller*' -ErrorAction SilentlyContinue | ForEach-Object { Copy-Item $_.FullName $Out -ErrorAction SilentlyContinue } +} + +# ---- Visual Studio ---------------------------------------------------------------------------- +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vs = (& $vswhere -latest -products * -format json | ConvertFrom-Json)[0] +Log "Visual Studio: $($vs.displayName) $($vs.installationVersion) [$($vs.instanceId)]" +$results.measurements.visual_studio = "$($vs.displayName) $($vs.installationVersion)" +$devenv = $vs.productPath +$installer = Join-Path $vs.installationPath 'Common7\IDE\VSIXInstaller.exe' + +$fx = Get-Content $Fixture -Raw | ConvertFrom-Json +$env:NUGET_PACKAGES = $fx.nuget_packages +$trace = Join-Path $Out 'trace.jsonl' +if (Test-Path $trace) { Remove-Item $trace } +$env:OWEN_LIVE_TRACE = $trace + +# install the VSIX like a user would, into the dedicated hive +$p = Start-Process $installer -ArgumentList "/quiet", "/rootSuffix:$RootSuffix", "/instanceIds:$($vs.instanceId)", "/logFile:$(Join-Path $Out 'vsixinstaller.log')", "`"$Vsix`"" -Wait -PassThru +Check "install-vsix" ($p.ExitCode -eq 0) "VSIXInstaller exit $($p.ExitCode) into root suffix $RootSuffix" +if ($p.ExitCode -ne 0) { $results | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $Out 'results.json'); exit 1 } + +# The MEF composition is cached per hive (ComponentModelCache). A hosted image ships that +# cache prebuilt, and installing an extension rebuilt the pkgdef cache but not this one (the +# run on 4289f6a: the cache files were byte-for-byte the image's, no part of Owen in them). +# Drop it so Visual Studio composes again with the installed extension, as /updateconfiguration +# would on a developer machine. +foreach ($h in (Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Microsoft\VisualStudio') -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -like "*$RootSuffix" })) { + $cache = Join-Path $h.FullName 'ComponentModelCache' + if (Test-Path $cache) { Remove-Item -Recurse -Force $cache; Log "dropped the MEF cache $cache" } +} +$u = Start-Process $devenv -ArgumentList "/rootsuffix", $RootSuffix, "/updateconfiguration" -Wait -PassThru +Log "devenv /updateconfiguration exit $($u.ExitCode)" + +$major = $vs.installationVersion.Split('.')[0] +$activity = Join-Path $Out 'ActivityLog.xml' +$t0 = Now +$proc = Start-Process $devenv -ArgumentList "/rootsuffix", $RootSuffix, "/log", "`"$activity`"", "`"$($fx.sln)`"" -PassThru +Log "devenv pid $($proc.Id)" +# A hive's first launch shows modal first-run dialogs (sign-in, then the environment +# settings); a modal dialog rejects every DTE call. Dismiss them the way a user would. +$firstRun = @('Skip and add accounts later.', 'Skip and add accounts later', 'Not now, maybe later.', 'Start Visual Studio') +function Dismiss-FirstRun { + $root = [System.Windows.Automation.AutomationElement]::RootElement + $byPid = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ProcessIdProperty, $proc.Id) + foreach ($w in $root.FindAll([System.Windows.Automation.TreeScope]::Children, $byPid)) { + foreach ($name in $firstRun) { + $c = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $name) + $el = $w.FindFirst([System.Windows.Automation.TreeScope]::Descendants, $c) + if ($el) { + Log "first-run dialog: '$name'" + try { $el.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() } + catch { + $r = $el.Current.BoundingRectangle + [Mouse]::SetCursorPos([int]($r.X + $r.Width / 2), [int]($r.Y + $r.Height / 2)) | Out-Null + [Mouse]::mouse_event(2, 0, 0, 0, [UIntPtr]::Zero); [Mouse]::mouse_event(4, 0, 0, 0, [UIntPtr]::Zero) + } + return $true + } + } + } + return $false +} +$dte = $null +for ($k = 0; $k -lt 180 -and -not $dte; $k++) { + Start-Sleep -Seconds 2 + try { [void](Dismiss-FirstRun) } catch { Log "dismiss: $_" } + if ($k % 30 -eq 0) { Shot ("startup-{0:D3}" -f $k) } + if ($proc.HasExited) { + Log "devenv exited ($($proc.ExitCode)); relaunching once (extension registration restart)" + $proc = Start-Process $devenv -ArgumentList "/rootsuffix", $RootSuffix, "/log", "`"$activity`"", "`"$($fx.sln)`"" -PassThru + } + try { $dte = [System.Runtime.InteropServices.Marshal]::GetActiveObject("VisualStudio.DTE.$major.0") } catch { } +} +if (-not $dte) { Check "dte" $false "no DTE after 360 s"; Shot "no-dte"; exit 1 } +Retry { try { [void](Dismiss-FirstRun) } catch { }; if (-not ($dte.Solution.IsOpen -and $dte.Solution.Projects.Count -gt 0)) { throw "loading" } } 300 | Out-Null +Log "solution open after $([int]((Now) - $t0) / 1000) s: $($dte.Solution.FullName)" + +$liveTxt = Join-Path (Split-Path $fx.project) 'obj\owen\live.txt' +# The editor must be REALIZED for its taggers to exist: a background tab is not. Close the +# start page VS opens after an update, open the Error List, and bring Use.cs to the front. +foreach ($w in @(Retry { $dte.Windows })) { + try { if ($w.Caption -like "What's new*" -or $w.Caption -like "Start Page*") { Log "closing '$($w.Caption)'"; $w.Close() } } catch { } +} +Retry { $dte.ExecuteCommand('View.ErrorList') } | Out-Null +$window = Retry { $dte.ItemOperations.OpenFile($fx.use) } +Retry { $window.Activate() } | Out-Null +Retry { $window.Visible = $true } | Out-Null +$doc = Retry { $dte.ActiveDocument } +Log "active document: $($doc.FullName)" +# The extension's own word that its MEF parts were composed and its monitor runs (a .NET +# Framework assembly does not reliably show among the process's native modules). +$loaded = $null +for ($k = 0; $k -lt 120 -and -not $loaded; $k++) { + if (Test-Path $trace) { $loaded = Select-String -Path $trace -Pattern '"Owen live analysis loaded"' -SimpleMatch -List } + if (-not $loaded) { Start-Sleep -Seconds 1 } +} +if (-not $loaded) { Collect-Diagnostics } +Check "vsix-loaded" ($null -ne $loaded) "Owen.VisualStudio composed in devenv: $(if ($loaded) { $loaded.Line } else { 'no load line in the trace after 120 s' })" +Shot "opened" +if (-not $loaded) { + # nothing below can pass without the extension in the process: stop with the evidence + $results.failed = $true + $results | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $Out 'results.json') -Encoding UTF8 + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + exit 1 +} +$td = Retry { $doc.Object('TextDocument') } + +# ---- trace ------------------------------------------------------------------------------------ +$script:traceSeen = 0 +$script:events = New-Object System.Collections.ArrayList +function Poll-Trace { + if (-not (Test-Path $trace)) { return } + $lines = @(Get-Content $trace -Encoding UTF8) + for ($i = $script:traceSeen; $i -lt $lines.Count; $i++) { + if ($lines[$i].Trim().Length -gt 0) { [void]$script:events.Add(($lines[$i] | ConvertFrom-Json)) } + } + $script:traceSeen = $lines.Count +} +function Tags-Since($since) { + Poll-Trace + @($script:events | Where-Object { $_.kind -eq 'tags' -and $_.t -ge $since -and $_.data.file -like '*Use.cs' }) +} +# Windows PowerShell gives a lone PSCustomObject no .Count: callers count @(Own002 $e). (A +# function returning ",@(...)" would make that a one-element array holding the array.) +function Own002($tagsEvent) { $tagsEvent.data.tags | Where-Object { $_.code -eq 'OWN002' } } +function Wait-Tags($since, [scriptblock]$Want, [int]$Seconds = 60) { + $deadline = (Get-Date).AddSeconds($Seconds) + while ((Get-Date) -lt $deadline) { + foreach ($e in (Tags-Since $since)) { if (& $Want $e) { return $e } } + Start-Sleep -Milliseconds 50 + } + return $null +} + +# ---- the Error List, as the user sees it ------------------------------------------------------ +function Error-Rows { + $root = [System.Windows.Automation.AutomationElement]::RootElement + $byPid = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ProcessIdProperty, $proc.Id) + $rows = @() + foreach ($w in $root.FindAll([System.Windows.Automation.TreeScope]::Children, $byPid)) { + $items = $w.FindAll([System.Windows.Automation.TreeScope]::Descendants, + (New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, [System.Windows.Automation.ControlType]::DataItem))) + foreach ($item in $items) { + $cells = @() + foreach ($c in $item.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition)) { + $n = $c.Current.Name + if ($n -and $cells -notcontains $n) { $cells += $n } + } + $text = ($cells -join ' | ') + if ($text -match '\b(OWN\d{3}|OWEN[BV]\d{3}|CS\d{4})\b') { + $rows += [pscustomobject]@{ Element = $item; Text = $text; Code = $Matches[1] } + } + } + } + return $rows +} +function Wait-Row($code, [bool]$present, [int]$Seconds = 60) { + $deadline = (Get-Date).AddSeconds($Seconds) + while ((Get-Date) -lt $deadline) { + $rows = @(Error-Rows | Where-Object { $_.Code -eq $code }) + if ($present -and $rows.Count -gt 0) { return $rows[0] } + if (-not $present -and $rows.Count -eq 0) { return $true } + Start-Sleep -Milliseconds 250 + } + return $null +} + +# ---- text edits through the editor (never saved) --------------------------------------------- +function All-Text { $ep = $td.StartPoint.CreateEditPoint(); return $ep.GetText($td.EndPoint) } +function Line-Of($needle) { + $lines = (All-Text) -split "`r?`n" + for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i].Trim() -eq $needle) { return $i + 1 } } + return -1 +} +function Insert-Line($before, $text) { + $ep = $td.StartPoint.CreateEditPoint(); $ep.MoveToLineAndOffset($before, 1); $ep.Insert($text + "`r`n") +} +function Delete-Line($line) { + $a = $td.StartPoint.CreateEditPoint(); $a.MoveToLineAndOffset($line, 1) + $b = $td.StartPoint.CreateEditPoint(); $b.MoveToLineAndOffset($line + 1, 1) + $a.Delete($b) +} +# VS may reopen its start page over the editor after the solution loads; an editor that is not +# visible is never asked for tags, so every measured edit first brings Use.cs to the front. +function Front-Use { + foreach ($w in @($dte.Windows)) { + try { if ($w.Caption -like "What's new*" -or $w.Caption -like "Start Page*") { $w.Close() } } catch { } + } + try { $script:window.Activate() } catch { Log "activate: $_" } +} +$stale = ' draft.Submit(now);' +$anchor = 'var submitted = draft.Submit(now);' +$region = 'OrderProtocol.WithDraft(order, draft =>' + +# ---- K1: the project's first analysis, and an unsaved clean edit ------------------------------ +Retry { if (-not (Test-Path $liveTxt)) { throw "no live.txt" } } 240 | Out-Null +Check "L0-live-request" (Test-Path $liveTxt) "the design-time build wrote $liveTxt (no build was run)" +$first = $null +$deadline = (Get-Date).AddSeconds(240) +while (-not $first -and (Get-Date) -lt $deadline) { + Poll-Trace + $first = @($script:events | Where-Object { $_.kind -eq 'publish' })[0] + Start-Sleep -Milliseconds 250 +} +if (-not $first) { + Poll-Trace + Log "trace tail: $(@($script:events | Select-Object -Last 15 | ForEach-Object { $_ | ConvertTo-Json -Compress -Depth 6 }) -join "`n")" + Check "first-publication" $false "no Owen publication within 240 s of opening Use.cs" + Shot "no-publication" +} +else { + $own = @($first.data.entries | Where-Object { $_.code -like 'OWN*' }) + Check "K1-open-clean" ($own.Count -eq 0) "first publication (version $($first.data.version), $($first.data.timing.total) ms in the service): $(@($first.data.entries).Count) entries, OWN: $($own.Count)" + $results.measurements.cold_open_to_first_publication_ms = $first.t - $t0 +} +$snap = @($script:events | Where-Object { $_.kind -eq 'snapshot' })[0] +if ($snap) { Log "snapshot: documents $($snap.data.documents -join ', '); generated $($snap.data.generated -join ', ')" } + +Front-Use +$since = Now +Insert-Line ((Line-Of $anchor) + 1) ' // an unsaved, harmless edit' +$cleanPub = $null +$deadline = (Get-Date).AddSeconds(60) +while (-not $cleanPub -and (Get-Date) -lt $deadline) { + Poll-Trace + $cleanPub = @($script:events | Where-Object { $_.kind -eq 'publish' -and $_.t -ge $since })[0] + Start-Sleep -Milliseconds 100 +} +Check "K1-unsaved-clean" ($cleanPub -and @($cleanPub.data.entries | Where-Object { $_.code -like 'OWN*' }).Count -eq 0 -and -not $doc.Saved) "a clean unsaved edit was analysed (version $($cleanPub.data.version)) with no OWN entry; document saved=$($doc.Saved)" +Delete-Line (Line-Of '// an unsaved, harmless edit') + +# ---- K2: the second Submit, unsaved ----------------------------------------------------------- +Front-Use +$since = Now +$insertAt = (Line-Of $anchor) + 1 +Insert-Line $insertAt $stale +$regionLine = Line-Of $region +$hit = Wait-Tags $since { param($e) $o = @(Own002 $e); ($o | Where-Object { -not $_.primary -and $_.line -eq $insertAt }) -and ($o | Where-Object { $_.primary -and $_.line -eq $regionLine }) } +Check "K2-squiggle" ($null -ne $hit) ("tags on Use.cs after the edit: " + $(if ($hit) { (@(Own002 $hit) | ForEach-Object { "$($_.line):$($_.column)+$($_.length) primary=$($_.primary) '$($_.text)'" }) -join '; ' } else { 'none with OWN002 at the region entry and on the inserted line' })) +if ($hit) { $results.measurements.k2_edit_to_squiggle_ms = $hit.t - $since } +Check "K2-unsaved" (-not $doc.Saved) "Use.cs is unsaved (Saved=$($doc.Saved)); the disk still has one Submit: $(-not ((Get-Content $fx.use -Raw) -match 'draft\.Submit\(now\);\s*\r?\n\s*draft\.Submit'))" +Start-Sleep -Milliseconds 700 +Shot "k2-squiggle" +$row = Wait-Row 'OWN002' $true 60 +Check "K2-error-list" ($null -ne $row) "Error List row: $(if ($row) { $row.Text } else { 'none' })" + +# navigation: double-click the row, then read the caret +if ($row) { try { + try { $row.Element.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() } catch { } + $r = $row.Element.Current.BoundingRectangle + [Mouse]::SetForegroundWindow((Get-Process -Id $proc.Id).MainWindowHandle) | Out-Null + [Mouse]::DoubleClick([int]($r.X + [Math]::Min(200, $r.Width / 2)), [int]($r.Y + $r.Height / 2)) + Start-Sleep -Milliseconds 1500 + $active = Retry { $dte.ActiveDocument } + $point = Retry { $active.Selection.ActivePoint } + $expectColumn = ((Retry { (All-Text) -split "`r?`n" })[$regionLine - 1]).IndexOf('OrderProtocol') + 1 + # the same file, whatever casing or form DTE reports its path in (the run on 7cdf529 failed + # this check with the caret exactly at 10:9 on a FullName that compared unequal by case) + $sameFile = [System.IO.Path]::GetFullPath($active.FullName).Equals([System.IO.Path]::GetFullPath($fx.use), [System.StringComparison]::OrdinalIgnoreCase) + Check "K2-navigation" ($sameFile -and $point.Line -eq $regionLine -and $point.LineCharOffset -eq $expectColumn) "double-click -> '$($active.FullName)' line $($point.Line) column $($point.LineCharOffset); expected '$($fx.use)' line $regionLine column $expectColumn (the core's location, §6)" + Shot "k2-navigated" +} catch { Check "K2-navigation" $false "navigation could not be driven: $($_.Exception.Message) at $($_.InvocationInfo.PositionMessage)" } } + +# ---- P9: a CS error beside OWN002 ------------------------------------------------------------- +$csAt = (Line-Of $region) +Insert-Line $csAt ' int notAString = "s";' +$cs = Wait-Row 'CS0029' $true 90 +$own = @(Error-Rows | Where-Object { $_.Code -eq 'OWN002' }) +Check "P9-cs-and-own" (($null -ne $cs) -and $own.Count -ge 1) "CS0029 row: $(if ($cs) { $cs.Text } else { 'none' }); OWN002 rows: $($own.Count)" +Shot "p9-cs-and-own" +Delete-Line (Line-Of 'int notAString = "s";') +[void](Wait-Row 'CS0029' $false 90) + +# ---- K3: delete the line ------------------------------------------------------------------------ +Start-Sleep -Milliseconds 1500 +Front-Use +$since = Now +Delete-Line (Line-Of 'draft.Submit(now);') +$gone = Wait-Tags $since { param($e) @(Own002 $e).Count -eq 0 } +$rowGone = Wait-Row 'OWN002' $false 60 +Check "K3-disappears" (($null -ne $gone) -and ($rowGone -eq $true)) "after deleting the line: tags without OWN002 at +$(if ($gone) { $gone.t - $since } else { '?' }) ms; Error List row gone: $($rowGone -eq $true)" +Shot "k3-gone" + +# ---- latency over repeated edits --------------------------------------------------------------- +$appear = @(); $vanish = @() +for ($i = 0; $i -lt $Repeats; $i++) { + Front-Use + Start-Sleep -Milliseconds 800 + $since = Now + Insert-Line ((Line-Of $anchor) + 1) $stale + $e = Wait-Tags $since { param($x) @(Own002 $x | Where-Object { -not $_.primary }).Count -gt 0 } 30 + if ($e) { $appear += ($e.t - $since) } + Start-Sleep -Milliseconds 800 + $since = Now + Delete-Line (Line-Of 'draft.Submit(now);') + $e = Wait-Tags $since { param($x) @(Own002 $x).Count -eq 0 } 30 + if ($e) { $vanish += ($e.t - $since) } +} +function P95($xs) { $s = @($xs | Sort-Object); if ($s.Count -eq 0) { return $null }; return $s[[Math]::Max(0, [int][Math]::Ceiling($s.Count * 0.95) - 1)] } +$results.measurements.edit_to_squiggle_ms = $appear +$results.measurements.edit_to_disappearance_ms = $vanish +Check "T-edit-to-squiggle" ($appear.Count -eq $Repeats -and (P95 $appear) -lt 1000) "edit -> squiggle over $($appear.Count)/$Repeats edits: p95 $(P95 $appear) ms (threshold 1000), all: $($appear -join ', ')" +Check "T-edit-to-disappearance" ($vanish.Count -eq $Repeats -and (P95 $vanish) -lt 1000) "edit -> disappearance over $($vanish.Count)/$Repeats edits: p95 $(P95 $vanish) ms (threshold 1000), all: $($vanish -join ', ')" + +Poll-Trace +$ui = @($script:events | Where-Object { $_.kind -eq 'ui' } | ForEach-Object { $_.data.ms } | Measure-Object -Maximum).Maximum +$results.measurements.ui_thread_max_ms = $ui +Check "T-ui-thread" ($ui -lt 50) "longest Owen UI-thread handler: $ui ms (threshold 50)" +$pubs = @($script:events | Where-Object { $_.kind -eq 'publish' }) +$service = @($pubs | ForEach-Object { $_.data.timing.total } | Where-Object { $_ }) +$results.measurements.service_total_ms = $service +$results.measurements.publications = $pubs.Count +$ws = @($pubs | ForEach-Object { $_.data.timing.working_set_kb } | Where-Object { $_ }) +if ($ws.Count -gt 0) { $results.measurements.service_working_set_kb_first_last = @($ws[0], $ws[-1]) } + +# ---- nothing was saved or built ------------------------------------------------------------------ +$projDir = Split-Path $fx.project +$dll = @(Get-ChildItem -Recurse -ErrorAction SilentlyContinue (Join-Path $projDir 'bin') -Filter 'LiveFixture.dll') +Check "no-build" ($dll.Count -eq 0 -and -not (Test-Path (Join-Path $projDir 'obj\owen\request.txt'))) "no LiveFixture.dll under bin/ ($($dll.Count)), no build-check request: the build host never ran" +Check "never-saved" ((Get-Content $fx.use -Raw) -eq (Get-Content (Join-Path $PSScriptRoot '..\..\tests\owen-live\LiveFixture\Use.cs.txt') -Raw)) "Use.cs on disk is byte-identical to the fixture" + +$results.failed = $failed +$results | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $Out 'results.json') -Encoding UTF8 +try { $dte.Documents.CloseAll(2) } catch { } +$services = @(Get-CimInstance Win32_Process -Filter "Name = 'dotnet.exe'" | Where-Object { $_.CommandLine -like '*ownsharp.dll*serve*' }) +Log "owen serve processes before devenv ends: $(@($services | ForEach-Object { $_.ProcessId }) -join ', ')" +Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue +Start-Sleep -Seconds 3 +$orphans = @(Get-CimInstance Win32_Process -Filter "Name = 'dotnet.exe'" | Where-Object { $_.CommandLine -like '*ownsharp.dll*serve*' }) +Check "no-orphan" ($services.Count -ge 1 -and $orphans.Count -eq 0) "owen serve ran ($($services.Count) process(es)) and did not outlive a killed devenv ($($orphans.Count) left)" +$results.failed = $failed +$results | ConvertTo-Json -Depth 8 | Set-Content (Join-Path $Out 'results.json') -Encoding UTF8 +if ($failed) { Log "VS acceptance: FAILED"; exit 1 } +Log "VS acceptance: all checks passed" +exit 0 diff --git a/scripts/vs/prepare_vs_fixture.py b/scripts/vs/prepare_vs_fixture.py new file mode 100644 index 00000000..d5d7da01 --- /dev/null +++ b/scripts/vs/prepare_vs_fixture.py @@ -0,0 +1,94 @@ +"""OX-02 P14: prepare the solution the real Visual Studio acceptance opens. + +Packs Owen.Build (with this machine's natively built Rust core) and Owen.TypedBuilder into a +local feed, then creates OUTSIDE the checkout a solution whose one project references +Owen.TypedBuilder and holds tests/owen-live/LiveFixture's Order.cs and Use.cs. It is restored +and NOT built: the acceptance must reach a live finding without any build. + +Writes /fixture.json: {"sln", "project", "use", "nuget_packages", "feed"}. + +Run: python scripts/vs/prepare_vs_fixture.py --out [--rust-core ] +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import owen_extension_gate as ox + +FIXTURE = os.path.join(ox.ROOT, "tests", "owen-live", "LiveFixture") + + +def main() -> int: + argv = sys.argv[1:] + out = os.path.abspath(argv[argv.index("--out") + 1]) + given = argv[argv.index("--rust-core") + 1] if "--rust-core" in argv else None + if os.path.exists(out): + shutil.rmtree(out) + os.makedirs(out) + stage = ox.stage_core(out, given) + feed = ox.pack(out, stage) + packages = os.path.join(out, "nuget") + ws = os.path.join(out, "ws") + project_dir = os.path.join(ws, "LiveFixture") + os.makedirs(project_dir) + env = dict( + os.environ, NUGET_PACKAGES=packages, DOTNET_CLI_TELEMETRY_OPTOUT="1", DOTNET_NOLOGO="1" + ) + with open(os.path.join(ws, "nuget.config"), "w", encoding="utf-8", newline="\n") as f: + f.write(f""" + + + + + + + + + + + +""") + + def dotnet(*args: str, cwd: str = project_dir) -> None: + done = subprocess.run( + ["dotnet", *args], cwd=cwd, env=env, capture_output=True, text=True, check=False + ) + if done.returncode != 0: + raise SystemExit( + f"FAIL: dotnet {' '.join(args)}: {done.stdout[-1500:]}{done.stderr[-800:]}" + ) + + dotnet("new", "classlib", "-n", "LiveFixture", "-o", ".", "--framework", "net8.0") + os.remove(os.path.join(project_dir, "Class1.cs")) + dotnet("add", "package", "Owen.TypedBuilder", "--version", ox.VERSION) + for name in ("Order.cs", "Use.cs"): + shutil.copyfile(os.path.join(FIXTURE, name + ".txt"), os.path.join(project_dir, name)) + dotnet("new", "sln", "-n", "LiveFixture", "--format", "sln", cwd=ws) + dotnet( + "sln", "LiveFixture.sln", "add", os.path.join("LiveFixture", "LiveFixture.csproj"), cwd=ws + ) + dotnet("restore", "LiveFixture.sln", cwd=ws) + for leftover in ("bin",): + if os.path.exists(os.path.join(project_dir, leftover)): + raise SystemExit(f"FAIL: the fixture has a {leftover}/ before Visual Studio opened it") + info = { + "sln": os.path.join(ws, "LiveFixture.sln"), + "project": os.path.join(project_dir, "LiveFixture.csproj"), + "use": os.path.join(project_dir, "Use.cs"), + "nuget_packages": packages, + "feed": feed, + } + with open(os.path.join(out, "fixture.json"), "w", encoding="utf-8") as f: + json.dump(info, f, indent=2) + print(json.dumps(info, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/spec/OwenExtension.md b/spec/OwenExtension.md index d8d66bff..e7532759 100644 --- a/spec/OwenExtension.md +++ b/spec/OwenExtension.md @@ -105,8 +105,20 @@ developer declared, and "could not be checked" must never read as clean. **Generator diagnostic:** `OWENTB001` (Typed Builder) means a refused declaration, one error per defect. +## 4a. The live host (OX-02): the same contract in the IDE + +An extension gets live diagnostics in Visual Studio without shipping any IDE code. The one +generic `Owen.VisualStudio` extension (a VSIX, installed once per machine) serves every active +extension through the same descriptors: + +- **The live request.** `Owen.Build.targets` writes `obj/owen/live.txt` (target `OwenLiveRequest`, after `CoreCompile`, design-time builds included). It carries the same `project` / `descriptor` / `generated-root` / `severity` lines as the build request, plus `host` (this package's `ownsharp.dll`) and `dotnet`. Visual Studio's own design-time build writes it on load: no build is needed. +- **The service.** `owen serve` (the same packed program) speaks `owen-live/1`: `Content-Length` framing of UTF-8 JSON on stdin/stdout, `hello` first, anything malformed fatal (exit 3). An `analyze` request names the live request and carries the editor's unsaved documents and source-generated documents. The service validates the descriptors exactly as `build-check` does (OWENB002/003/004 live), takes the same input set, reads the editor's text instead of the disk, runs the ONE extractor in-process, and returns the Rust core's findings (its SARIF renderer: code, level, message, location, witness steps). +- **Generated sources.** An in-memory generated document whose path contains a declared generator's name (`…///`) is used in place of that generator's last build output on disk. Matched by generator name, never by extension. +- **Parity.** For the same saved sources a build and the live service report the same findings (code, severity, message, file, line). The live host additionally carries the column (when the core has one) and the witness steps; the msbuild line has neither. +- **Its own condition.** `OWENV001` (Owen.VisualStudio): the live service could not start or died. Shown in the Error List, restarted on the next edit at most three times in five minutes, then live analysis stays off (and says so) until the solution is reopened. Builds are unaffected. + ## 5. What the contract deliberately does not have - **Dynamic loading of analysis code.** Extensions add frontends and vocabulary. The analysis is the one Rust core, and its semantics change only through OwnIR (spec/OwnIR.md). -- **Per-extension IDE integration.** A future `Owen.VisualStudio` or language server serves every active extension from the same manifest. +- **Per-extension IDE integration.** `Owen.VisualStudio` (§4a) serves every active extension from the same descriptors; an extension never ships IDE code. - **Version ranges.** `requires.host` is a lower bound. The host refuses what it does not understand; it does not guess. diff --git a/tests/check_extractor_in_process.py b/tests/check_extractor_in_process.py new file mode 100644 index 00000000..6e02ed01 --- /dev/null +++ b/tests/check_extractor_in_process.py @@ -0,0 +1,213 @@ +"""OX-02 P2: the extractor run as a library writes what the command line writes. + +`InProcessExtractor.Run` (frontend/roslyn/OwnSharp.Extractor/InProcess.cs) is how the IDE +service runs the ONE extractor program inside a long-lived process. This check holds it to +the command line: + + E1 every job, run in-process, writes facts byte-identical to `ownsharp-extract` run as a + process with the same arguments in the same directory, and exits with the same code; + E2 the same, with the jobs run in the reverse order inside one process: no run leaks + state into the next; + E3 an overlay equal to the file on disk changes nothing; + E4 an overlay that differs from the disk is read INSTEAD of the disk: the facts equal a + command-line run over a copy of the tree with that file changed on disk, and differ + from the facts of the unchanged tree (an implementation that kept reading the disk + would fail here: mutation M6). + +Run: python tests/check_extractor_in_process.py (needs the .NET 8 SDK) +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +EXTRACTOR = os.path.join(ROOT, "frontend", "roslyn", "OwnSharp.Extractor") +DRIVER = os.path.join(ROOT, "tests", "owen-live", "InProcessDriver") +SAMPLES = "frontend/roslyn/samples" +PROTO = "frontend/roslyn/protocol-samples" + +FLAG_SETS: list[list[str]] = [ + [], + ["--flow-locals"], + ["--flow-locals", "--stats"], + ["--fix-candidates"], + ["--flow-locals", "--body-throw-edges"], + ["--no-event-leaks", "--flow-locals"], +] + + +def _run(cmd: list[str], cwd: str = ROOT) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + + +def _build(project: str) -> None: + done = _run(["dotnet", "build", project, "-c", "Release", "-nologo", "-v", "q"]) + if done.returncode != 0: + print(done.stdout[-3000:]) + raise SystemExit(f"FAIL: {project} does not build") + + +def _jobs() -> list[tuple[str, list[str]]]: + """(label, arguments without -o); every job runs with the repository root as cwd.""" + jobs: list[tuple[str, list[str]]] = [] + for flags in FLAG_SETS: + jobs.append((f"samples {' '.join(flags)}", [SAMPLES, *flags])) + for name in sorted(os.listdir(os.path.join(ROOT, SAMPLES))): + if name.endswith(".cs"): + jobs.append((f"samples/{name}", [f"{SAMPLES}/{name}", "--flow-locals"])) + for name in sorted(os.listdir(os.path.join(ROOT, PROTO, "cases"))): + if name.endswith(".cs"): + jobs.append( + (f"cases/{name}", [f"{PROTO}/Api", f"{PROTO}/cases/{name}", "--flow-locals"]) + ) + for sub in ("same-assembly", "unrelated"): + jobs.append((sub, [f"{PROTO}/{sub}", "--flow-locals"])) + jobs.append(("heap-effects-samples", ["frontend/roslyn/heap-effects-samples", "--flow-locals"])) + jobs.append( + ( + "project-input-sample", + ["frontend/roslyn/project-input-sample/ProjectInputSample.csproj", "--flow-locals"], + ) + ) + jobs.append( + ("OrderBackend", ["samples/OrderBackend/OrderBackend/OrderBackend.csproj", "--flow-locals"]) + ) + return jobs + + +def _cli(dll: str, args: list[str], out: str, cwd: str = ROOT) -> int: + return _run(["dotnet", "exec", dll, *args, "-o", out], cwd=cwd).returncode + + +def _in_process(driver: str, jobs: list[dict[str, object]], tmp: str) -> list[int]: + path = os.path.join(tmp, "jobs.jsonl") + with open(path, "w", encoding="utf-8") as f: + for job in jobs: + f.write(json.dumps(job) + "\n") + done = _run(["dotnet", "exec", driver, path]) + if done.returncode != 0: + raise SystemExit( + f"FAIL: the in-process driver exited {done.returncode}: {done.stderr[-2000:]}" + ) + lines = [ln for ln in done.stdout.splitlines() if ln.strip()] + return [int(json.loads(ln)["rc"]) for ln in lines] + + +def _read(path: str) -> bytes | None: + try: + with open(path, "rb") as f: + return f.read() + except FileNotFoundError: + return None + + +def main() -> int: + _build(EXTRACTOR) + _build(DRIVER) + dll = os.path.join(EXTRACTOR, "bin", "Release", "net8.0", "ownsharp-extract.dll") + driver = os.path.join(DRIVER, "bin", "Release", "net8.0", "InProcessDriver.dll") + fails: list[str] = [] + checks = 0 + jobs = _jobs() + with tempfile.TemporaryDirectory() as tmp: + cli_out = [os.path.join(tmp, f"cli-{i}.json") for i in range(len(jobs))] + cli_rc = [_cli(dll, args, out) for (_, args), out in zip(jobs, cli_out, strict=True)] + + for order, label in ( + (list(range(len(jobs))), "E1"), + (list(reversed(range(len(jobs)))), "E2"), + ): + outs = {i: os.path.join(tmp, f"{label}-{i}.json") for i in order} + rcs = _in_process( + driver, [{"cwd": ROOT, "args": [*jobs[i][1], "-o", outs[i]]} for i in order], tmp + ) + for i, rc in zip(order, rcs, strict=True): + checks += 1 + name = jobs[i][0] + if rc != cli_rc[i]: + fails.append(f"{label} {name}: exit {rc}, the command line exited {cli_rc[i]}") + elif _read(outs[i]) != _read(cli_out[i]): + fails.append(f"{label} {name}: facts differ from the command line's") + + # E3/E4: the samples directory, one file overlaid. Self-contained on purpose: a run + # whose facts depend on a built bin/ (OrderBackend's EF types) would refuse on a + # clean checkout and compare no facts with no facts. + project = SAMPLES + target = os.path.join(ROOT, project, "FlowLocalsSample.cs") + with open(target, encoding="utf-8") as f: + original = f.read() + same = os.path.join(tmp, "same.cs") + with open(same, "w", encoding="utf-8", newline="") as f: + f.write(original) + # a non-escaping disposable local: the flow pass emits a function for it + changed_text = original + ( + "\nstatic class OverlayProbe { static void M() " + "{ var s = new System.IO.MemoryStream(); s.WriteByte(1); } }\n" + ) + changed = os.path.join(tmp, "changed.cs") + with open(changed, "w", encoding="utf-8", newline="") as f: + f.write(changed_text) + args = [project, "--flow-locals"] + base_out = os.path.join(tmp, "e3-base.json") + _cli(dll, args, base_out) + e3 = os.path.join(tmp, "e3.json") + e4 = os.path.join(tmp, "e4.json") + _in_process( + driver, + [ + {"cwd": ROOT, "args": [*args, "-o", e3], "overlay": {target: same}}, + {"cwd": ROOT, "args": [*args, "-o", e4], "overlay": {target: changed}}, + ], + tmp, + ) + checks += 1 + if _read(e3) != _read(base_out): + fails.append("E3: an overlay equal to the disk changed the facts") + # the same change made on disk, in a copy of the tree, run by the command line + copy = os.path.join(tmp, "tree") + shutil.copytree(os.path.join(ROOT, project), os.path.join(copy, project)) + with open( + os.path.join(copy, project, "FlowLocalsSample.cs"), "w", encoding="utf-8", newline="" + ) as f: + f.write(changed_text) + disk_out = os.path.join(tmp, "e4-disk.json") + _cli(dll, args, disk_out, cwd=copy) + checks += 1 + e4_bytes = _read(e4) + if _read(base_out) is None: + fails.append("E4: the unchanged samples produced no facts; nothing was compared") + elif e4_bytes is None or e4_bytes == _read(base_out): + fails.append( + "E4: an overlay that differs from the disk did not change the facts " + "(the disk was read instead of the buffer)" + ) + elif e4_bytes != _read(disk_out): + fails.append( + "E4: overlay facts differ from the command line's over the same contents on disk" + ) + + for failure in fails: + print(f"FAIL: {failure}") + print( + f"extractor in-process: {checks - len(fails)}/{checks} passed, {len(fails)} failed " + f"({len(jobs)} jobs)" + ) + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/owen-extensions/Owen.TestProtocol/Owen.TestProtocol.csproj b/tests/owen-extensions/Owen.TestProtocol/Owen.TestProtocol.csproj new file mode 100644 index 00000000..e94fba60 --- /dev/null +++ b/tests/owen-extensions/Owen.TestProtocol/Owen.TestProtocol.csproj @@ -0,0 +1,31 @@ + + + + netstandard2.0 + 12 + enable + Owen.TestProtocol.Generator + true + true + true + Owen.TestProtocol + 0.1.0 + Test fixture: a synthetic Owen extension with a generator. Not published. + PhysShell + false + false + $(NoWarn);NU5128 + + + + + + + + + + + diff --git a/tests/owen-extensions/Owen.TestProtocol/TurnstileGenerator.cs b/tests/owen-extensions/Owen.TestProtocol/TurnstileGenerator.cs new file mode 100644 index 00000000..675ef4db --- /dev/null +++ b/tests/owen-extensions/Owen.TestProtocol/TurnstileGenerator.cs @@ -0,0 +1,76 @@ +// The synthetic second extension's generator (OX-02 live gate). It adds ONE fixed source: a +// turnstile state protocol in the shape Own.NET's state-protocol lowering reads (a +// [ProtocolRegion] entry handing a [ProtocolToken] ref struct to a callback; every token +// method a transition). The attributes are matched by name, so they are declared here, in +// this extension's own namespace. + +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Owen.TestProtocol +{ + [Generator(LanguageNames.CSharp)] + public sealed class TurnstileGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) => + context.RegisterPostInitializationOutput(static post => + post.AddSource("Turnstile.Protocol.g.cs", SourceText.From(Source, Encoding.UTF8))); + + private const string Source = @"// Generated by Owen.TestProtocol (a synthetic test extension). +#nullable enable +using System; + +namespace Owen.TestProtocol +{ + [AttributeUsage(AttributeTargets.Struct)] + internal sealed class ProtocolTokenAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Method)] + internal sealed class ProtocolRegionAttribute : Attribute { } + + public enum TurnstileState { Locked, Unlocked } + + public sealed class Turnstile + { + public TurnstileState State { get; internal set; } + } + + [ProtocolToken] + public readonly ref struct LockedTurnstile + { + private readonly Turnstile _turnstile; + internal LockedTurnstile(Turnstile turnstile) => _turnstile = turnstile; + + public UnlockedTurnstile Coin() + { + _turnstile.State = TurnstileState.Unlocked; + return new UnlockedTurnstile(_turnstile); + } + } + + [ProtocolToken] + public readonly ref struct UnlockedTurnstile + { + private readonly Turnstile _turnstile; + internal UnlockedTurnstile(Turnstile turnstile) => _turnstile = turnstile; + + public void Push() => _turnstile.State = TurnstileState.Locked; + } + + public delegate void LockedRegion(LockedTurnstile locked); + + public static class TurnstileProtocol + { + [ProtocolRegion] + public static void WithLocked(Turnstile turnstile, LockedRegion body) + { + if (turnstile.State != TurnstileState.Locked) + throw new InvalidOperationException(""the turnstile is not locked""); + body(new LockedTurnstile(turnstile)); + } + } +} +"; + } +} diff --git a/tests/owen-extensions/Owen.TestProtocol/buildTransitive/Owen.TestProtocol.props b/tests/owen-extensions/Owen.TestProtocol/buildTransitive/Owen.TestProtocol.props new file mode 100644 index 00000000..56a6fb18 --- /dev/null +++ b/tests/owen-extensions/Owen.TestProtocol/buildTransitive/Owen.TestProtocol.props @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/owen-extensions/Owen.TestProtocol/buildTransitive/owen-extension.json b/tests/owen-extensions/Owen.TestProtocol/buildTransitive/owen-extension.json new file mode 100644 index 00000000..68572374 --- /dev/null +++ b/tests/owen-extensions/Owen.TestProtocol/buildTransitive/owen-extension.json @@ -0,0 +1,13 @@ +{ + "owen_extension": 1, + "id": "Owen.TestProtocol", + "version": "0.1.0", + "requires": { + "host": "0.1.0", + "ownir": 2, + "capabilities": ["state-protocol"] + }, + "frontend": { + "generators": ["Owen.TestProtocol.Generator"] + } +} diff --git a/tests/owen-live/InProcessDriver/InProcessDriver.csproj b/tests/owen-live/InProcessDriver/InProcessDriver.csproj new file mode 100644 index 00000000..58c596e1 --- /dev/null +++ b/tests/owen-live/InProcessDriver/InProcessDriver.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + false + + + + + diff --git a/tests/owen-live/InProcessDriver/Program.cs b/tests/owen-live/InProcessDriver/Program.cs new file mode 100644 index 00000000..ebb676bb --- /dev/null +++ b/tests/owen-live/InProcessDriver/Program.cs @@ -0,0 +1,19 @@ +// jobs file: one JSON object per line +// {"cwd": "...", "args": ["..."], "overlay": {"": ""}} +// prints one JSON line per job: {"rc": n, "output": "..."} +using System.Text.Json; +using OwnSharp.Extractor; + +foreach (var line in File.ReadLines(args[0])) +{ + if (line.Length == 0) + continue; + using var job = JsonDocument.Parse(line); + var root = job.RootElement; + var jobArgs = root.GetProperty("args").EnumerateArray().Select(a => a.GetString()!).ToList(); + Dictionary? overlay = null; + if (root.TryGetProperty("overlay", out var o)) + overlay = o.EnumerateObject().ToDictionary(p => p.Name, p => File.ReadAllText(p.Value.GetString()!)); + var (rc, output) = InProcessExtractor.Run(jobArgs, root.GetProperty("cwd").GetString()!, overlay); + Console.WriteLine(JsonSerializer.Serialize(new { rc, output })); +} diff --git a/tests/owen-live/LiveClientTests/LiveClientTests.csproj b/tests/owen-live/LiveClientTests/LiveClientTests.csproj new file mode 100644 index 00000000..9d98a15a --- /dev/null +++ b/tests/owen-live/LiveClientTests/LiveClientTests.csproj @@ -0,0 +1,19 @@ + + + + Exe + net8.0 + enable + latest + false + enable + + + + + + diff --git a/tests/owen-live/LiveClientTests/Program.cs b/tests/owen-live/LiveClientTests/Program.cs new file mode 100644 index 00000000..ec93d883 --- /dev/null +++ b/tests/owen-live/LiveClientTests/Program.cs @@ -0,0 +1,159 @@ +// usage: LiveClientTests [ ] (the service tests need both) +using System.Diagnostics; +using Owen.VisualStudio.Live; + +var failures = new List(); +var passed = 0; +void Check(string name, bool holds, string detail) +{ + Console.WriteLine($"{(holds ? "ok" : "FAIL")}[{name}]: {detail}"); + if (holds) passed++; else failures.Add(name); +} + +// ---- coordinates (preregistration §6; mutation M3 moves them) ------------------------------ +{ + var (s, e, c) = LiveCoordinates.Span(" draft.Submit(now); ", null); + Check("coord-no-column", (s, e, c) == (12, 30, 13), $"trimmed line -> [{s},{e}) column {c}"); + (s, e, c) = LiveCoordinates.Span(" x = y;", 9); + Check("coord-column", (s, e, c) == (8, 10, 9), $"column 9 -> [{s},{e}) column {c}"); + (s, e, c) = LiveCoordinates.Span("", null); + Check("coord-empty", (s, e, c) == (0, 0, 1), $"empty line -> [{s},{e}) column {c}"); +} + +// ---- the entries a response becomes --------------------------------------------------------- +{ + var file = Path.Combine(Path.GetTempPath(), "Use.cs"); + var text = "a\nb\n region(x, d =>\n {\n d.Go();\n d.Go();\n });\n"; + var snapshot = new LiveSnapshot("/p/P.csproj", "/p/obj/owen/live.txt", [new LiveSource(file, text)], []); + var response = new LiveResponse + { + Status = "ok", + Diagnostics = + [ + new LiveDiagnostic + { + Code = "OWN002", Severity = "warning", Message = "used after", File = file, Line = 3, Origin = "core", + Related = [new LiveLocation { File = file, Line = 3, Message = "acquired here" }, + new LiveLocation { File = file, Line = 6, Message = "used here" }], + }, + ], + }; + var entries = LiveEngine.Entries(response, snapshot); + var primary = entries.Where(x => x.Primary).ToList(); + var witness = entries.Where(x => !x.Primary).ToList(); + Check("entries-one-row", primary.Count == 1 && primary[0].Line == 3 && primary[0].Column == 3, + $"one Error List row at the core's location: {string.Join(", ", primary.Select(p => $"{p.Line}:{p.Column}"))}"); + Check("entries-witness-squiggle", witness.Count == 1 && witness[0].Line == 6 && witness[0].Start == 4 && witness[0].End == 11 + && witness[0].Message.StartsWith("used here", StringComparison.Ordinal), + $"the witness step on line 6 is a squiggle only: {string.Join(", ", witness.Select(w => $"{w.Line}[{w.Start},{w.End}) '{w.Message}'"))}"); +} + +// ---- K4: a late answer never replaces a newer one ------------------------------------------- +async Task> RaceAsync(bool ignoreVersions) +{ + var published = new List(); + var analyzer = new FakeAnalyzer(); + var engine = new LiveEngine(_ => Task.FromResult(null), _ => analyzer, _ => { }, TimeSpan.FromMilliseconds(10)) + { + IgnoreVersions = ignoreVersions, + }; + engine.Published += p => { lock (published) published.Add(p.Version); }; + var snap = new LiveSnapshot("K", "R", [], []); + var v1 = engine.Issue("K"); + var v2 = engine.Issue("K"); + analyzer.Delays[v1] = 400; // N answers after N+1 + analyzer.Delays[v2] = 10; + await Task.WhenAll(engine.RunAsync(snap, v1), engine.RunAsync(snap, v2)); + return published; +} +{ + var published = await RaceAsync(ignoreVersions: false); + Check("K4-stale-dropped", published.SequenceEqual([2L]), $"published versions: [{string.Join(", ", published)}] (version 1 answered last)"); + var mutated = await RaceAsync(ignoreVersions: true); + Check("K4-M1-control", mutated.LastOrDefault() == 1, + $"with the version check removed (M1) the stale answer is published last: [{string.Join(", ", mutated)}]"); +} + +// ---- bursts coalesce ------------------------------------------------------------------------- +{ + var snapshots = 0; + var engine = new LiveEngine(_ => { Interlocked.Increment(ref snapshots); return Task.FromResult(null); }, + _ => null, _ => { }, TimeSpan.FromMilliseconds(250)); + for (var i = 0; i < 20; i++) + { + engine.OnEdit("K"); + await Task.Delay(30); + } + await Task.Delay(600); + Check("burst-coalesced", snapshots is >= 1 and <= 2, $"20 edits 30 ms apart -> {snapshots} analysis snapshot(s)"); +} + +// ---- the real service: death, restart, budget (K6), a service that cannot start (M2) -------- +if (args.Length >= 2) +{ + var host = Path.GetFullPath(args[0]); + var request = Path.GetFullPath(args[1]); + var dotnet = Process.GetCurrentProcess().MainModule!.FileName; + var log = new List(); + var clock = DateTime.UtcNow; + using var supervisor = new LiveSupervisor(dotnet, host, l => { lock (log) log.Add(l); }, () => clock); + var key = File.ReadAllLines(request).First(l => l.StartsWith("project\t", StringComparison.Ordinal))[8..]; + var first = await supervisor.AnalyzeAsync(key, 1, request, [], []); + var pid1 = supervisor.ProcessId; + Check("service-answers", first.Status == "ok", $"status {first.Status}, {first.Diagnostics.Count} diagnostic(s), pid {pid1}"); + + supervisor.KillService(); + await Task.Delay(1000); + Check("K6-death-shown", supervisor.Condition is string c1 && c1.Contains("exited", StringComparison.Ordinal), + $"condition after a crash: {supervisor.Condition}"); + var engine = new LiveEngine(_ => Task.FromResult(null), _ => new SupervisedAnalyzer(supervisor), _ => { }, TimeSpan.Zero); + var rows = new List(); + engine.Published += p => rows.AddRange(p.Entries); + var snap = new LiveSnapshot(key, request, [], []); + await engine.RunAsync(snap, engine.Issue(key)); + var pid2 = supervisor.ProcessId; + Check("K6-restarted", pid2 != pid1 && supervisor.Condition is null, $"the next request restarted it (pid {pid1} -> {pid2}); condition {supervisor.Condition ?? "cleared"}"); + + for (var i = 0; i < 3; i++) + { + supervisor.KillService(); + await Task.Delay(800); + try { await supervisor.AnalyzeAsync(key, 10 + i, request, [], []); } catch (LiveServiceDiedException) { } + } + supervisor.KillService(); + await Task.Delay(800); + rows.Clear(); + await engine.RunAsync(snap, engine.Issue(key)); + Check("K6-budget", supervisor.GaveUp && rows.Count == 1 && rows[0].Code == LiveEngine.ServiceCode + && rows[0].Message.Contains("off until the solution is reopened", StringComparison.Ordinal), + $"after {LiveSupervisor.MaxRestarts} restarts in the window: gave up={supervisor.GaveUp}, row: {rows.FirstOrDefault()?.Code} '{rows.FirstOrDefault()?.Message}'"); + + using var broken = new LiveSupervisor(dotnet, host + ".missing", _ => { }); + var brokenEngine = new LiveEngine(_ => Task.FromResult(null), _ => new SupervisedAnalyzer(broken), _ => { }, TimeSpan.Zero); + var brokenRows = new List(); + brokenEngine.Published += p => brokenRows.AddRange(p.Entries); + await brokenEngine.RunAsync(snap, brokenEngine.Issue(key)); + Check("M2-cannot-start", brokenRows.Count == 1 && brokenRows[0].Code == LiveEngine.ServiceCode, + $"a service that cannot start is one visible row: {brokenRows.FirstOrDefault()?.Code} '{brokenRows.FirstOrDefault()?.Message}'"); +} +else +{ + Console.WriteLine("skip[service]: no ownsharp.dll / live.txt given"); +} + +Console.WriteLine($"live client tests: {passed} passed, {failures.Count} failed"); +return failures.Count == 0 ? 0 : 1; + +sealed class FakeAnalyzer : ILiveAnalyzer +{ + public Dictionary Delays { get; } = []; + + public string? Condition => null; + + public async Task AnalyzeAsync(string key, long version, string request, + IReadOnlyList documents, IReadOnlyList generated) + { + await Task.Delay(Delays.TryGetValue(version, out var d) ? d : 0); + return new LiveResponse { Key = key, Version = version, Status = "ok" }; + } +} diff --git a/tests/owen-live/LiveFixture/Gate.cs.txt b/tests/owen-live/LiveFixture/Gate.cs.txt new file mode 100644 index 00000000..7e44dd0b --- /dev/null +++ b/tests/owen-live/LiveFixture/Gate.cs.txt @@ -0,0 +1,15 @@ +using Owen.TestProtocol; + +namespace LiveFixture; + +public static class Gate +{ + public static void Pass(Turnstile turnstile) + { + TurnstileProtocol.WithLocked(turnstile, locked => + { + var unlocked = locked.Coin(); + unlocked.Push(); + }); + } +} diff --git a/tests/owen-live/LiveFixture/Order.cs.txt b/tests/owen-live/LiveFixture/Order.cs.txt new file mode 100644 index 00000000..9056d76e --- /dev/null +++ b/tests/owen-live/LiveFixture/Order.cs.txt @@ -0,0 +1,51 @@ +using System; + +namespace LiveFixture; + +public enum OrderStatus +{ + Draft, + Submitted, + Approved, + Shipped, +} + +/// An ordinary EF Core entity — no base class, no interface — and the declaration its +/// protocol is generated from (Order.Protocol.cs). EF materializes it through the private +/// constructor and tracks THIS instance; every state token wraps the same reference. +[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; } + + public DateTime? SubmittedAt { get; private set; } + + public DateTime? ApprovedAt { get; private set; } + + public DateTime? ShippedAt { get; private set; } + + public int? TrackingNumber { get; private set; } + + [Transition("Submit", OrderStatus.Draft, OrderStatus.Submitted)] + private void OnSubmit(DateTime at) => SubmittedAt = at; + + [Transition("Approve", OrderStatus.Submitted, OrderStatus.Approved)] + private void OnApprove(DateTime at) => ApprovedAt = at; + + [Transition("Ship", OrderStatus.Approved, OrderStatus.Shipped)] + private void OnShip(DateTime at, int trackingNumber) + { + ShippedAt = at; + TrackingNumber = trackingNumber; + } +} diff --git a/tests/owen-live/LiveFixture/README.md b/tests/owen-live/LiveFixture/README.md new file mode 100644 index 00000000..b81e6f86 --- /dev/null +++ b/tests/owen-live/LiveFixture/README.md @@ -0,0 +1,12 @@ +# OX-02 live fixture + +The sources of the small project the live gate (`scripts/owen_live_gate.py`) and the real +Visual Studio run (`scripts/vs/owen-vs-acceptance.ps1`) analyse. Stored as `.cs.txt` so no +scan of this repository picks them up; both copy them, as `.cs`, into a project created +outside the checkout that references `Owen.TypedBuilder` (and, for K8, `Owen.TestProtocol`). + +- `Order.cs` — the TB-MVP `Order`, declared for the Typed Builder generator. +- `Use.cs` — one clean `WithDraft` region. The acceptance inserts a second + `draft.Submit(now);` after `var submitted = draft.Submit(now);` (OWN002) and deletes it. +- `Gate.cs` — the synthetic second extension's turnstile, clean (K8 inserts a second + `locked.Coin();`). diff --git a/tests/owen-live/LiveFixture/Use.cs.txt b/tests/owen-live/LiveFixture/Use.cs.txt new file mode 100644 index 00000000..f8768d46 --- /dev/null +++ b/tests/owen-live/LiveFixture/Use.cs.txt @@ -0,0 +1,16 @@ +using System; + +namespace LiveFixture; + +public static class Use +{ + public static void Run(Order order) + { + var now = DateTime.UnixEpoch; + OrderProtocol.WithDraft(order, draft => + { + var submitted = draft.Submit(now); + submitted.Approve(now); + }); + } +}