Skip to content

Commit 5b89a93

Browse files
committed
OX-02: live-host docs, Snipper reuse inventory, mutation controls M1-M6
- spec/OwenExtension.md §4a: the live request, owen serve, generated-source routing, parity, OWENV001. - docs/howto-visual-studio.md: installing and using Owen.VisualStudio, known limits. - docs/notes/owen-visual-studio-snipper-reuse.md: the four ADAPTed chunks, origin, licence. - scripts/owen_live_mutations.py: each mutation edits one product source, its registered check must go red, the source is restored. - prepare_vs_fixture.py: drop an unused noqa (ruff RUF100). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZFvhLx1fM9Gerg4dKsZcL
1 parent 1c2a98e commit 5b89a93

5 files changed

Lines changed: 224 additions & 2 deletions

File tree

‎docs/howto-visual-studio.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,33 @@ so it never breaks a wrapping build by accident.
5858

5959
## 2. In Visual Studio
6060

61+
### Live: Owen.VisualStudio (OX-02) — for projects that use an Owen extension
62+
63+
If the project references an Owen extension package (for example `Owen.TypedBuilder`, which
64+
brings `Owen.Build`), install the one generic **Owen** VSIX
65+
(`frontend/roslyn/Owen.VisualStudio`, built on Windows with Visual Studio's MSBuild:
66+
`msbuild frontend\roslyn\Owen.VisualStudio\Owen.VisualStudio.csproj /restore /p:Configuration=Release`,
67+
then double-click `Owen.VisualStudio.vsix`). Requirements: Visual Studio 2022 17.14+ or Visual
68+
Studio 2026, and the .NET 8+ runtime that `Owen.Build` already needs. No Python, Rust, checkout
69+
or global `owen` tool.
70+
71+
What you get, while typing and without saving or building:
72+
- 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);
73+
- one **Error List** row per finding (Source/Tool `Owen`), double-click to navigate; CS diagnostics stay the compiler's;
74+
- the **Owen** output pane: the live service's own log, including its stderr.
75+
76+
How it works and what it does not do: `docs/notes/owen-visual-studio-preregistration.md`. In
77+
short, the VSIX sends the editor's text to `owen serve` from the project's own `Owen.Build`
78+
package, and the Rust core decides; the VSIX decides nothing. `dotnet build` stays the
79+
authoritative gate, with the same findings (the build/live parity gate holds them equal).
80+
81+
Known limits: a state-protocol finding has no column (the squiggle covers the reported line);
82+
a stale token is reported at the region entry with the stale use as its witness step (#393); a
83+
file that was never saved is not part of the project's input set; live analysis starts after the
84+
project's first design-time build (Visual Studio runs it on load).
85+
86+
### Without an extension
87+
6188
Two approaches, cheapest first.
6289

6390
### Option A — External Tool (on-demand, no build coupling) — recommended
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# OX-02 P11: what Owen.VisualStudio reuses from Snipper
2+
3+
Source: `PhysShell/snipper` at `43b395a`, MIT licence (its `LICENSE`). Only audited
4+
infrastructure was taken, and only by **adaptation** (nothing is vendored or copied verbatim).
5+
The OX-01 audit (`owen-extension-snipper-salvage.md`) rejected Snipper's long-lived process
6+
lifecycle (stderr lost, crashes unwatched, orphans possible); none of it is used here.
7+
8+
| # | chunk in Owen.NET | origin in Snipper | verdict | what changed |
9+
|---|---|---|---|---|
10+
| 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 |
11+
| 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 |
12+
| 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 |
13+
| 4 | `tests/owen-live/LiveClientTests/LiveClientTests.csproj`: the VS-independent sources compiled into a test project by `<Compile Link>` | `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` |
14+
15+
**Not reused**, by decision: the LSP client spine (`SnipperLanguageServerProvider`, StreamJsonRpc),
16+
the PATH-fallback binary locator, Snipper's commands/options page, its process lifecycle.
17+
`owen-live/1` is a small purpose-built framing (`Content-Length` + JSON, fatal on anything
18+
malformed); the service lifecycle is new (`LiveService`/`LiveSupervisor`: stderr kept, death
19+
detected and shown, restart budget, a Windows job object so the child never outlives devenv).

‎scripts/owen_live_mutations.py‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""OX-02 mutation controls M1-M6 (docs/notes/owen-visual-studio-preregistration.md §12).
2+
3+
Each mutation edits ONE product source the way the bug it stands for would, runs the check
4+
that is registered to catch it, and requires that check to go red; the source is restored
5+
whatever happens. A mutation that leaves its check green is a FAIL of this script.
6+
7+
M1 the client publishes every answer, current or not -> LiveClientTests K4-stale-dropped
8+
M2 a service that cannot start is swallowed silently -> LiveClientTests M2-cannot-start
9+
M3 the coordinate contract is off by one -> LiveClientTests coord-*
10+
M4 the host routes only Typed Builder's generated sources -> owen_live_gate K8-two-extensions
11+
M5 live forms its own severity instead of the core's -> owen_live_gate P10-parity-*
12+
M6 the unsaved document is read from disk -> owen_live_gate K2 + extractor E4
13+
14+
Run: python scripts/owen_live_mutations.py [--rust-core <own-cli>] [--only M1,M3]
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import os
20+
import subprocess
21+
import sys
22+
import tempfile
23+
24+
ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
25+
VS = os.path.join(ROOT, "frontend", "roslyn", "Owen.VisualStudio", "Live")
26+
CLI = os.path.join(ROOT, "frontend", "roslyn", "OwnSharp.Cli")
27+
EXTRACTOR = os.path.join(ROOT, "frontend", "roslyn", "OwnSharp.Extractor")
28+
TESTS = os.path.join(ROOT, "tests", "owen-live", "LiveClientTests")
29+
30+
MUTATIONS: list[tuple[str, str, str, str, str, list[str]]] = [
31+
(
32+
"M1",
33+
os.path.join(VS, "LiveEngine.cs"),
34+
"if (!IgnoreVersions && !_gate.IsCurrent(snapshot.Key, response.Version))",
35+
"if (false && !_gate.IsCurrent(snapshot.Key, response.Version))",
36+
"client",
37+
["K4-stale-dropped"],
38+
),
39+
(
40+
"M2",
41+
os.path.join(VS, "LiveEngine.cs"),
42+
" if (IgnoreVersions || _gate.IsCurrent(snapshot.Key, version))\n"
43+
" Publish(",
44+
" if (false)\n Publish(",
45+
"client",
46+
["M2-cannot-start"],
47+
),
48+
(
49+
"M3",
50+
os.path.join(VS, "LiveScheduler.cs"),
51+
"return (start, end, start + 1);",
52+
"return (start + 1, end, start + 2);",
53+
"client",
54+
["coord-no-column", "coord-column"],
55+
),
56+
(
57+
"M4",
58+
os.path.join(CLI, "OwenHost.cs"),
59+
"descriptors.SelectMany(d => d.Generators)",
60+
'descriptors.Where(d => d.Id == "Owen.TypedBuilder").SelectMany(d => d.Generators)',
61+
"gate",
62+
["K8-two-extensions"],
63+
),
64+
(
65+
"M5",
66+
os.path.join(CLI, "LiveAnalysis.cs"),
67+
'level == "error" ? "error" : "warning",',
68+
'"error",',
69+
"gate",
70+
["P10-parity-stale", "P10-parity-copy", "P10-parity-raw"],
71+
),
72+
(
73+
"M6",
74+
os.path.join(EXTRACTOR, "InProcess.cs"),
75+
"SourceOverlay is not null && "
76+
"SourceOverlay.TryGetValue(Path.GetFullPath(path), out var text)",
77+
"false && SourceOverlay!.TryGetValue(Path.GetFullPath(path), out var text)",
78+
"gate",
79+
["K2-unsaved-own002"],
80+
),
81+
]
82+
83+
84+
def run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
85+
return subprocess.run(
86+
cmd,
87+
cwd=ROOT,
88+
capture_output=True,
89+
text=True,
90+
encoding="utf-8",
91+
errors="replace",
92+
check=False,
93+
)
94+
95+
96+
def red(output: str, names: list[str]) -> list[str]:
97+
return [n for n in names if f"FAIL[{n}]" in output]
98+
99+
100+
def main() -> int:
101+
argv = sys.argv[1:]
102+
core = argv[argv.index("--rust-core") + 1] if "--rust-core" in argv else None
103+
only = set(argv[argv.index("--only") + 1].split(",")) if "--only" in argv else None
104+
failures = []
105+
# the client tests' service half needs a real `owen serve` and a live request; a request
106+
# with no extension is enough (the service answers OWENB001), so build the host locally
107+
built = run(["dotnet", "build", CLI, "-nologo", "-v", "q"])
108+
if built.returncode != 0:
109+
raise SystemExit(f"FAIL: OwnSharp.Cli does not build: {built.stdout[-800:]}")
110+
host = os.path.join(CLI, "bin", "Debug", "net8.0", "ownsharp.dll")
111+
scratch = tempfile.mkdtemp(prefix="owen-live-mutations-")
112+
request = os.path.join(scratch, "live.txt")
113+
with open(request, "w", encoding="utf-8") as f:
114+
f.write(f"project\t{os.path.join(scratch, 'Empty.csproj')}\nhost\t{host}\n")
115+
for name, path, before, after, where, expect in MUTATIONS:
116+
if only is not None and name not in only:
117+
continue
118+
with open(path, encoding="utf-8") as f:
119+
original = f.read()
120+
if original.count(before) != 1:
121+
failures.append(
122+
f"{name}: its anchor is not exactly once in {os.path.relpath(path, ROOT)}"
123+
)
124+
continue
125+
try:
126+
with open(path, "w", encoding="utf-8", newline="") as f:
127+
f.write(original.replace(before, after))
128+
if where == "client":
129+
built = run(["dotnet", "build", TESTS, "-c", "Release", "-nologo", "-v", "q"])
130+
if built.returncode != 0:
131+
failures.append(
132+
f"{name}: LiveClientTests no longer builds: {built.stdout[-500:]}"
133+
)
134+
continue
135+
done = run(
136+
[
137+
"dotnet",
138+
os.path.join(TESTS, "bin", "Release", "net8.0", "LiveClientTests.dll"),
139+
host,
140+
request,
141+
]
142+
)
143+
else:
144+
cmd = [sys.executable, os.path.join(ROOT, "scripts", "owen_live_gate.py")]
145+
if core:
146+
cmd += ["--rust-core", core]
147+
done = run(cmd)
148+
caught = red(done.stdout, expect)
149+
if caught:
150+
print(f"ok[{name}]: caught by {caught} (exit {done.returncode})")
151+
else:
152+
failures.append(f"{name}: none of {expect} went red (exit {done.returncode})")
153+
print(done.stdout[-3000:])
154+
finally:
155+
with open(path, "w", encoding="utf-8", newline="") as f:
156+
f.write(original)
157+
for failure in failures:
158+
print(f"FAIL[{failure.split(':')[0]}]: {failure}")
159+
print(f"owen live mutations: {len(failures)} not caught")
160+
return 1 if failures else 0
161+
162+
163+
if __name__ == "__main__":
164+
raise SystemExit(main())

‎scripts/vs/prepare_vs_fixture.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import sys
2020

2121
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22-
import owen_extension_gate as ox # noqa: E402
22+
import owen_extension_gate as ox
2323

2424
FIXTURE = os.path.join(ox.ROOT, "tests", "owen-live", "LiveFixture")
2525

‎spec/OwenExtension.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,20 @@ developer declared, and "could not be checked" must never read as clean.
105105
**Generator diagnostic:** `OWENTB001` (Typed Builder) means a refused declaration, one error per
106106
defect.
107107

108+
## 4a. The live host (OX-02): the same contract in the IDE
109+
110+
An extension gets live diagnostics in Visual Studio without shipping any IDE code. The one
111+
generic `Owen.VisualStudio` extension (a VSIX, installed once per machine) serves every active
112+
extension through the same descriptors:
113+
114+
- **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.
115+
- **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).
116+
- **Generated sources.** An in-memory generated document whose path contains a declared generator's name (`…/<generator>/<type>/<hint>`) is used in place of that generator's last build output on disk. Matched by generator name, never by extension.
117+
- **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.
118+
- **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.
119+
108120
## 5. What the contract deliberately does not have
109121

110122
- **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).
111-
- **Per-extension IDE integration.** A future `Owen.VisualStudio` or language server serves every active extension from the same manifest.
123+
- **Per-extension IDE integration.** `Owen.VisualStudio` (§4a) serves every active extension from the same descriptors; an extension never ships IDE code.
112124
- **Version ranges.** `requires.host` is a lower bound. The host refuses what it does not understand; it does not guess.

0 commit comments

Comments
 (0)