From ee6583a1d8ee4f324d8f0c68cf4a34e8d50e78f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 06:13:45 +0000 Subject: [PATCH 1/2] Add OwnSharp.Cli: the single `ownsharp check` command (alpha gate A, #202) Implements the packaging decision recorded in issue #202: one dotnet global tool (`OwnSharp.Cli`, command `ownsharp`) that wraps the existing extractor -> core pipeline into one install, exactly mirroring what scripts/own-check.sh already does by hand. - The Roslyn extractor (OwnSharp.Extractor) is pulled in via ProjectReference, unmodified; `check` invokes its bundled dll as a child process (`dotnet exec`), the same subprocess shape own-check.sh already uses. - The Python core (ownlang/) is vendored as loose *.py content, unmodified, and unpacked to ~/.ownsharp/core// on first run -- never into the analyzed repo. It runs on the machine's own Python (OWN_PYTHON env var, else `py -3`/`python3`, >=3.11), with a fast, actionable, one-line failure (winget/apt/brew per OS) and no auto-download if none is found. - CLI flags mirror own-check.sh 1:1 (--format, --severity, --fail-on-finding, --emit-facts, --legacy, --stats, --body-throw-edges); the exit-code contract (0/1/>=2, plus 3 for "no Python") is unchanged from the scripts. No behaviour changes to the extractor or core -- packaging only, per the issue's guardrail. own-check.sh/.ps1 and action.yml are untouched. CI: a new ownsharp-cli-smoke job (matrix: ubuntu-latest + windows-latest) proves pack -> dotnet tool install --global -> ownsharp check finds a real leak on a clean runner, gated on a regression-ceiling timer, plus a separate assertion that the no-Python path fails fast with the actionable message. Docs: frontend/roslyn/OwnSharp.Cli/README.md (the tool itself), updates to alpha-readiness.md gate A, P-013's Scope, and the root README's local-quickstart paragraph -- all noting honestly that the package isn't published to nuget.org yet (build-and-install from source until then). --- .github/workflows/ci.yml | 86 ++++++ README.md | 8 +- README.ru.md | 6 +- docs/notes/alpha-readiness.md | 21 +- docs/proposals/P-013-distribution-surface.md | 11 + frontend/roslyn/OwnSharp.Cli/CheckCommand.cs | 252 ++++++++++++++++++ frontend/roslyn/OwnSharp.Cli/CoreVendor.cs | 52 ++++ .../roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj | 56 ++++ frontend/roslyn/OwnSharp.Cli/Program.cs | 52 ++++ .../roslyn/OwnSharp.Cli/PythonResolver.cs | 142 ++++++++++ frontend/roslyn/OwnSharp.Cli/README.md | 88 ++++++ frontend/roslyn/OwnSharp.Cli/ToolVersion.cs | 14 + frontend/roslyn/README.md | 5 +- 13 files changed, 779 insertions(+), 14 deletions(-) create mode 100644 frontend/roslyn/OwnSharp.Cli/CheckCommand.cs create mode 100644 frontend/roslyn/OwnSharp.Cli/CoreVendor.cs create mode 100644 frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj create mode 100644 frontend/roslyn/OwnSharp.Cli/Program.cs create mode 100644 frontend/roslyn/OwnSharp.Cli/PythonResolver.cs create mode 100644 frontend/roslyn/OwnSharp.Cli/README.md create mode 100644 frontend/roslyn/OwnSharp.Cli/ToolVersion.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91c72c29..f3e47580 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1543,3 +1543,89 @@ jobs: # use, and an injected-source region-escape. A drop below the floor is a regression. run: python scripts/benchmark.py --min-recall 25 + # 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: OwnSharp.Cli bundles the *unmodified* extractor + # (ProjectReference; invoked as a child process, same shape own-check.sh + # already uses) and vendors the *unmodified* ownlang/ core, run by the + # machine's own Python. Both ubuntu AND windows matter here specifically + # (not just "more coverage") — a dotnet-tool shim is a native apphost on + # Windows and a shell script on Unix, so they exercise genuinely different + # process-launch mechanics; ubuntu-only would not prove the Windows path. + ownsharp-cli-smoke: + name: ownsharp CLI (gate A) — clean install -> check -> findings + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + # bash (git-bash on Windows runners) so one script works on both legs; + # the thing under test is the ownsharp/dotnet/python binaries, not the + # shell driving them. + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4 + with: + dotnet-version: "8.0.x" + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: Put the dotnet global-tools shim dir on PATH + run: echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" + # CI-only stand-in for "download the package from nuget.org" (not + # published there yet, see P-013's Non-goals) -- pack from the source + # this job already checked out. Deliberately OUTSIDE the timed window + # below: it is not part of the "install -> check" claim being proven. + - name: Pack OwnSharp.Cli (pulls in the extractor via ProjectReference) + run: dotnet pack frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj -c Release -o "$RUNNER_TEMP/ownsharp-nupkg" + - name: A minimal leak, in a scratch dir OUTSIDE the repo + # Proves the tool needs nothing but itself + Python -- not the Own.NET + # checkout (a real end user obviously won't have this repo on disk). + run: | + mkdir -p "$RUNNER_TEMP/ownsharp-sample" + cat > "$RUNNER_TEMP/ownsharp-sample/Leak.cs" <<'EOF' + using System.IO; + public class Leaky + { + public void Run() + { + var s = new MemoryStream(); + s.WriteByte(1); + } + } + EOF + - name: Start the clean-machine timer (install -> check -> findings) + run: echo "SMOKE_START=$(date +%s)" >> "$GITHUB_ENV" + - name: dotnet tool install --global (the one install the user runs) + run: dotnet tool install --global OwnSharp.Cli --version 0.1.0 --add-source "$RUNNER_TEMP/ownsharp-nupkg" + - name: ownsharp check finds the leak + run: | + set +e + out=$(ownsharp check "$RUNNER_TEMP/ownsharp-sample" --fail-on-finding 2>&1) + rc=$? + set -e + echo "$out" + [ "$rc" -eq 1 ] || { echo "FAIL: expected exit 1 (findings), got $rc"; exit 1; } + echo "$out" | grep -q "OWN001" || { echo "FAIL: expected OWN001 in the output"; exit 1; } + - name: Stop the timer -- report it, and gate on a generous regression ceiling + # A hard ceiling, not a precision claim: CI timing varies with runner + # load, so this is a regression guard (catch "it now takes 20 minutes"), + # not a rubber stamp of the "~3 minutes" marketing number itself. + run: | + elapsed=$(( $(date +%s) - SMOKE_START )) + echo "install -> check -> findings: ${elapsed}s" + [ "$elapsed" -lt 240 ] || { echo "FAIL: took ${elapsed}s (ceiling 240s) — see alpha-readiness.md gate A"; exit 1; } + - name: No Python found -> a fast, actionable failure (never an auto-download) + run: | + set +e + out=$(OWN_PYTHON=/definitely/does/not/exist/python3 ownsharp check "$RUNNER_TEMP/ownsharp-sample" 2>&1) + rc=$? + set -e + echo "$out" + [ "$rc" -eq 3 ] || { echo "FAIL: expected exit 3 (Python not found), got $rc"; exit 1; } + echo "$out" | grep -qi "OWN_PYTHON" || { echo "FAIL: expected the OWN_PYTHON-specific message"; exit 1; } + echo "$out" | grep -Eiq "winget|apt|brew|python.org" || { echo "FAIL: expected an actionable install hint"; exit 1; } + diff --git a/README.md b/README.md index b88ce07f..459f6eac 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,10 @@ scripts/own-check.sh --format human -- /path/to/your/csharp/repo ``` Needs Python 3.11+ and the .NET SDK on `PATH` — nothing to build, nothing to -`pip install` (there's no packaged CLI yet; see -[`docs/notes/alpha-readiness.md`](docs/notes/alpha-readiness.md) gate **A**). +`pip install`. A packaged single-command CLI (`ownsharp check`) also exists — +build-and-install-locally today, not yet published to nuget.org; see +[`frontend/roslyn/OwnSharp.Cli/README.md`](frontend/roslyn/OwnSharp.Cli/README.md) +and [`docs/notes/alpha-readiness.md`](docs/notes/alpha-readiness.md) gate **A**. ## One it actually found @@ -918,6 +920,8 @@ ownlang/ test_spec.py # conformance: every spec/ rule fires on an example test_ownir.py # the OwnIR bridge: C# facts -> core -> OWN001 at the C# site frontend/roslyn/ # the C# extractor (Roslyn, CI-only) + .cs samples (P-001) + OwnSharp.Extractor/ # ownsharp-extract (dotnet tool): facts only + OwnSharp.Cli/ # ownsharp (dotnet tool, gate A): extractor + vendored core, one install rust/ # the Rust core migration (P-022): own-ir + own-syntax so far, # oracle-gated against this Python core — see rust/README.md pyproject.toml # gate: ruff + mypy --strict (see below) diff --git a/README.ru.md b/README.ru.md index 25b9020d..ec3badef 100644 --- a/README.ru.md +++ b/README.ru.md @@ -28,8 +28,10 @@ scripts/own-check.sh --format human -- /путь/к/вашему/csharp/репо ``` Нужны Python 3.11+ и .NET SDK в `PATH` — ничего собирать, ничего ставить через -`pip install` (упакованного CLI пока нет; см. -[`docs/notes/alpha-readiness.md`](docs/notes/alpha-readiness.md), gate **A**). +`pip install`. Есть и упакованный однокомандный CLI (`ownsharp check`) — сегодня +собирается и ставится локально, в nuget.org ещё не опубликован; см. +[`frontend/roslyn/OwnSharp.Cli/README.md`](frontend/roslyn/OwnSharp.Cli/README.md) +и [`docs/notes/alpha-readiness.md`](docs/notes/alpha-readiness.md), gate **A**. ## Один реальный баг, который он нашёл diff --git a/docs/notes/alpha-readiness.md b/docs/notes/alpha-readiness.md index 71c6079d..8ab90640 100644 --- a/docs/notes/alpha-readiness.md +++ b/docs/notes/alpha-readiness.md @@ -36,7 +36,7 @@ The bar for "showable": a person can reproduce the wow in ~3 minutes | | Item | Status (2026-06-27) | Gap to close | |---|------|--------------------|--------------| -| **A** | `dotnet tool` one-command CLI | ◑ **partial** — the *extractor* is `PackAsTool` (`ownsharp-extract`, P-013); the core is Python. The delightful `ownsharp check MyApp.sln` single tool isn't packaged. | Wrap extractor+core into one `dotnet tool` (or a self-contained CLI) that takes a `.sln`/dir and prints findings. | +| **A** | `dotnet tool` one-command CLI | ◑ **mostly built** (issue #202) — `OwnSharp.Cli` wraps extractor+core into one `dotnet tool install` → `ownsharp check `, proven install→check→findings on a clean ubuntu/windows runner in CI (`ownsharp-cli-smoke`). See [`frontend/roslyn/OwnSharp.Cli/README.md`](../../frontend/roslyn/OwnSharp.Cli/README.md). | Not published to nuget.org yet — today it's build-and-install-from-source only. Publishing (+ a real version scheme beyond `0.1.0`) is the remaining step. | | **B** | GitHub Action | ✅ **built** — `action.yml`: `path`/`severity`/`format` (`github` / `msbuild` / `human` / `sarif`), purple shield branding. Matches the "stupidly simple YAML" bar. | Publish to Marketplace; pin the 6-line usage in the README. | | **C** | SARIF / PR annotations | ✅ **built** — SARIF 2.1.0 + GitHub annotations + reachability/evidence (P-015). | — | | **D** | 5 core diagnostics | ✅ **built, well past** — OWN001/002/003, OWN014, DI001–005, POOL001–005, WPF001–005 (catalog). The comment's `SUB001/SUB002/TMR001/DISP001/DI001` all exist *semantically*; the `SUB/TMR/DISP` catalog rename is the deferred consolidation item, not new work. | (naming only) land the catalog rename with the OwnIR-v1/profile-label work. | @@ -54,17 +54,19 @@ suppression → "why not Sonar/CodeQL"), every step of that path now exists. ## Honest verdict -**The engine is past alpha on *capability* (D/E strong, B/C built). F/G and the -front door have since closed too — the remaining packaging gap is narrower:** +**The engine is past alpha on *capability* (D/E strong, B/C built). F/G, the +front door, and now A have all closed too — what's left is publishing, not +building:** -1. a single `ownsharp check MyApp.sln` tool (**A**) — still open; +1. ~~a single `ownsharp check MyApp.sln` tool (**A**)~~ — **built**, not yet published to nuget.org; 2. ~~a wedge landing README + copy-paste quickstart (front door)~~ — **done**; 3. ~~three packaged case studies from finds we already have (**F**)~~ — **done**; 4. ~~one consolidated suppression / false-positive page (**G**)~~ — **done**. None of those is research; all are the difference between "interesting PoC" and -"people install it." **A** is now the one item standing between here and the -day 1–30 milestone. +"people install it." Publishing `OwnSharp.Cli` to nuget.org is now the one item +standing between here and the day 1–30 milestone being *literally* copy-paste +for a stranger. ## The 20% rule (other stacks) @@ -85,9 +87,10 @@ until the .NET alpha above is delicious. Do not let the spike exceed 20%. ## 90-day shape (sequencing, not a schedule) -- **Days 1–30 — make the .NET alpha tasty:** close A (B/C/D/E/F/G and the README - front door already done). Suppression UX + bad/ok corpus polish continue as - bug-driven follow-ups, not a blocking gate. +- **Days 1–30 — make the .NET alpha tasty:** publish `OwnSharp.Cli` to + nuget.org (A/B/C/D/E/F/G and the README front door are all otherwise done). + Suppression UX + bad/ok corpus polish continue as bug-driven follow-ups, not + a blocking gate. - **Days 31–60 — real-world proof:** run over 20–50 OSS .NET/WPF/Avalonia/WinForms repos; table of findings / confirmed / FP / unsupported; 2 case studies; compare with CodeQL / NetAnalyzers / Infer# where possible (the oracle, `docs/notes/oracle.md`). diff --git a/docs/proposals/P-013-distribution-surface.md b/docs/proposals/P-013-distribution-surface.md index 4337c8c6..789dad98 100644 --- a/docs/proposals/P-013-distribution-surface.md +++ b/docs/proposals/P-013-distribution-surface.md @@ -65,6 +65,17 @@ and defer the native analyzer until (if ever) the core itself moves to .NET. (`dotnet tool install --global OwnSharp.Extractor` → `ownsharp-extract`). Honest caveat: the tool is only the C# *extractor*; the verdict is still the Python core. The script/Action are the complete product. +- **`OwnSharp.Cli`** (alpha gate A, issue #202) — the single-install answer to + that caveat: `dotnet tool install --global OwnSharp.Cli` → `ownsharp check + ` wraps both stages in one tool. It bundles the *unmodified* + extractor (`ProjectReference`, invoked as a child process) and vendors the + *unmodified* `ownlang/` core (run on the machine's own Python, resolved via + `OWN_PYTHON`/`py -3`/`python3`, `>=3.11`, fail-fast otherwise — never an + auto-download). See [`frontend/roslyn/OwnSharp.Cli/README.md`](../../frontend/roslyn/OwnSharp.Cli/README.md) + for the packaging shape and the rejected alternatives on record in the issue. + Not yet published to nuget.org (Non-goals below, unchanged) — + build-and-install from source until then; `own-check.sh`/`.ps1`/`action.yml` + are untouched and remain the supported surfaces alongside it. ## Not the same as P-011 diff --git a/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs b/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs new file mode 100644 index 00000000..89c3716a --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs @@ -0,0 +1,252 @@ +using System.Diagnostics; + +namespace OwnSharp.Cli; + +/// +/// `ownsharp check` — extract (bundled Roslyn extractor, in a child process) +/// -> facts.json -> the vendored core (system Python) -> render. Flags mirror +/// scripts/own-check.sh 1:1; the exit-code contract is the same one (own-check +/// comment): 0 clean, 1 findings, >=2 a hard error, plus --fail-on-finding. +/// +internal static class CheckCommand +{ + private static readonly HashSet ValidFormats = ["human", "github", "msbuild", "sarif"]; + private static readonly HashSet ValidSeverities = ["error", "warning"]; + + public static async Task RunAsync(string[] args) + { + string format; + string severity; + bool failOnFinding; + bool legacy; + bool stats; + bool bodyThrowEdges; + string? emitFacts; + List paths; + try + { + (format, severity, failOnFinding, legacy, stats, bodyThrowEdges, emitFacts, paths) = ParseArgs(args); + } + catch (InvalidOperationException ex) + { + Console.Error.WriteLine(ex.Message); + return 2; + } + + if (!ValidFormats.Contains(format)) + { + Console.Error.WriteLine( + $"ownsharp check: unknown --format '{format}' (choose: {string.Join(", ", ValidFormats)})"); + return 2; + } + if (!ValidSeverities.Contains(severity)) + { + Console.Error.WriteLine( + $"ownsharp check: unknown --severity '{severity}' (choose: {string.Join(", ", ValidSeverities)})"); + return 2; + } + if (paths.Count == 0) + { + paths.Add("."); + } + + // Resolve Python FIRST: no point extracting facts just to fail on stage 2. + ResolvedPython python; + try + { + python = PythonResolver.Resolve(); + } + catch (PythonNotFoundException ex) + { + Console.Error.WriteLine(ex.Message); + return 3; + } + + var factsPath = Path.GetTempFileName(); + try + { + var extractRc = await RunExtractorAsync(paths, factsPath, legacy, stats, bodyThrowEdges) + .ConfigureAwait(false); + if (extractRc != 0) + { + return extractRc; + } + + if (emitFacts is not null) + { + File.Copy(factsPath, emitFacts, overwrite: true); + } + + var cacheRoot = CoreVendor.EnsureUnpacked(); + var rc = await RunCoreAsync(python, cacheRoot, factsPath, format, severity).ConfigureAwait(false); + + if (failOnFinding) + { + return rc; + } + return rc >= 2 ? rc : 0; + } + finally + { + try { File.Delete(factsPath); } catch (IOException) { /* best-effort cleanup */ } + } + } + + private static (string Format, string Severity, bool FailOnFinding, bool Legacy, bool Stats, + bool BodyThrowEdges, string? EmitFacts, List Paths) ParseArgs(string[] args) + { + var format = "human"; + var severity = "error"; + var failOnFinding = false; + var legacy = false; + var stats = false; + var bodyThrowEdges = false; + string? emitFacts = null; + var paths = new List(); + var onlyPaths = false; // true after a bare `--` + + for (var i = 0; i < args.Length; i++) + { + var a = args[i]; + if (onlyPaths) + { + paths.Add(a); + continue; + } + switch (a) + { + case "--": onlyPaths = true; break; + case "--format": format = RequireValue(args, ref i, "--format"); break; + case "--severity": severity = RequireValue(args, ref i, "--severity"); break; + case "--emit-facts": emitFacts = RequireValue(args, ref i, "--emit-facts"); break; + case "--fail-on-finding": failOnFinding = true; break; + case "--legacy": legacy = true; break; + case "--stats": stats = true; break; + case "--body-throw-edges": bodyThrowEdges = true; break; + default: paths.Add(a); break; + } + } + + return (format, severity, failOnFinding, legacy, stats, bodyThrowEdges, emitFacts, paths); + } + + private static string RequireValue(string[] args, ref int i, string flag) + { + if (i + 1 >= args.Length) + { + throw new InvalidOperationException($"ownsharp check: {flag} requires a value"); + } + return args[++i]; + } + + /// Stage 1: run the bundled extractor as a child process. All of its + /// own output (build/run chatter, if any) goes to OUR stderr, keeping + /// stdout clean for stage 2 — same as own-check.sh's `1>&2` on this stage. + private static async Task RunExtractorAsync( + IReadOnlyList paths, string factsPath, bool legacy, bool stats, bool bodyThrowEdges) + { + var extractorDll = Path.Combine(AppContext.BaseDirectory, "OwnSharp.Extractor.dll"); + if (!File.Exists(extractorDll)) + { + Console.Error.WriteLine( + $"ownsharp: bundled extractor not found at '{extractorDll}' — a corrupt or " + + "incomplete tool install. Try `dotnet tool uninstall --global OwnSharp.Cli` and reinstall."); + return 2; + } + + var psi = new ProcessStartInfo(ResolveDotnetMuxer()) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + psi.ArgumentList.Add("exec"); + psi.ArgumentList.Add(extractorDll); + foreach (var p in paths) + { + psi.ArgumentList.Add(p); + } + psi.ArgumentList.Add("-o"); + psi.ArgumentList.Add(factsPath); + if (!legacy) + { + psi.ArgumentList.Add("--flow-locals"); + } + if (stats) + { + psi.ArgumentList.Add("--stats"); + } + if (bodyThrowEdges) + { + psi.ArgumentList.Add("--body-throw-edges"); + } + + using var proc = Process.Start(psi) + ?? throw new InvalidOperationException("ownsharp: failed to start the extractor process"); + var stdoutTask = proc.StandardOutput.ReadToEndAsync(); + var stderrTask = proc.StandardError.ReadToEndAsync(); + await proc.WaitForExitAsync().ConfigureAwait(false); + var stdout = await stdoutTask.ConfigureAwait(false); + var stderr = await stderrTask.ConfigureAwait(false); + if (stdout.Length > 0) Console.Error.Write(stdout); + if (stderr.Length > 0) Console.Error.Write(stderr); + return proc.ExitCode; + } + + /// The `dotnet` muxer used to `exec` the bundled extractor dll. A + /// dotnet *tool* install requires the .NET SDK/runtime already on PATH + /// (that's how `dotnet tool install` itself runs), so a bare "dotnet" PATH + /// lookup is the reliable default; DOTNET_ROOT (set by some CI/sandboxed + /// installs) is honored first when present. Deliberately NOT + /// Process.GetCurrentProcess().MainModule — on Windows a `dotnet tool` + /// shim is a native apphost, so that would resolve to ownsharp.exe itself, + /// not the dotnet muxer. + private static string ResolveDotnetMuxer() + { + var root = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + if (!string.IsNullOrEmpty(root)) + { + var exeName = OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; + var candidate = Path.Combine(root, exeName); + if (File.Exists(candidate)) + { + return candidate; + } + } + return "dotnet"; + } + + /// Stage 2: the one checker, run against the vendored core via the + /// resolved system Python. Findings print to the real stdout/stderr — this + /// is the surface the user actually asked for. + private static async Task RunCoreAsync( + ResolvedPython python, string cacheRoot, string factsPath, string format, string severity) + { + var psi = new ProcessStartInfo(python.FileName) + { + UseShellExecute = false, + WorkingDirectory = cacheRoot, + }; + foreach (var a in python.LeadingArgs) + { + psi.ArgumentList.Add(a); + } + psi.ArgumentList.Add("-m"); + psi.ArgumentList.Add("ownlang"); + psi.ArgumentList.Add("ownir"); + psi.ArgumentList.Add(factsPath); + psi.ArgumentList.Add("--format"); + psi.ArgumentList.Add(format); + psi.ArgumentList.Add("--severity"); + psi.ArgumentList.Add(severity); + // Belt-and-suspenders alongside WorkingDirectory: `-m` already adds the + // cwd to sys.path[0], but own-check.sh/.ps1 both set PYTHONPATH + // explicitly too, and matching that is cheap insurance. + psi.EnvironmentVariables["PYTHONPATH"] = cacheRoot; + + using var proc = Process.Start(psi) + ?? throw new InvalidOperationException("ownsharp: failed to start the Python core process"); + await proc.WaitForExitAsync().ConfigureAwait(false); + return proc.ExitCode; + } +} diff --git a/frontend/roslyn/OwnSharp.Cli/CoreVendor.cs b/frontend/roslyn/OwnSharp.Cli/CoreVendor.cs new file mode 100644 index 00000000..52b3a31f --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/CoreVendor.cs @@ -0,0 +1,52 @@ +namespace OwnSharp.Cli; + +/// +/// Unpacks the vendored ownlang/ Python source (packed into this tool's +/// own nupkg under ownlang-core/ownlang/*.py, see the .csproj) into a +/// stable, per-version cache directory outside the tool's own (versioned, +/// nested) install path — and, per the design decision in issue #202, never +/// into the repository being analyzed. +/// +internal static class CoreVendor +{ + /// + /// Ensures the vendored core is unpacked for the running tool's version and + /// returns the directory that must be the working directory / PYTHONPATH + /// for `python -m ownlang ...` (i.e. the parent of the `ownlang` package + /// directory, exactly like own-check.sh's `PYTHONPATH="$root"`). + /// + public static string EnsureUnpacked() + { + var cacheRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".ownsharp", "core", ToolVersion.Current); + var destOwnlang = Path.Combine(cacheRoot, "ownlang"); + var marker = Path.Combine(cacheRoot, ".unpacked"); + + if (File.Exists(marker)) + { + return cacheRoot; + } + + var sourceOwnlang = Path.Combine(AppContext.BaseDirectory, "ownlang-core", "ownlang"); + if (!Directory.Exists(sourceOwnlang)) + { + throw new InvalidOperationException( + $"ownsharp: vendored core not found at '{sourceOwnlang}' — a corrupt or " + + "incomplete tool install. Try `dotnet tool uninstall --global OwnSharp.Cli` " + + "and reinstall."); + } + + Directory.CreateDirectory(destOwnlang); + foreach (var file in Directory.EnumerateFiles(sourceOwnlang, "*.py")) + { + var dest = Path.Combine(destOwnlang, Path.GetFileName(file)); + File.Copy(file, dest, overwrite: true); + } + // Write the marker LAST: an interrupted copy (killed process, full disk) + // leaves no marker, so the next run redoes the unpack instead of running + // against a half-written core. + File.WriteAllText(marker, ToolVersion.Current); + return cacheRoot; + } +} diff --git a/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj new file mode 100644 index 00000000..916fc77b --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj @@ -0,0 +1,56 @@ + + + + Exe + net8.0 + enable + enable + ownsharp + + + true + ownsharp + OwnSharp.Cli + 0.1.0 + Own.NET's single command: `ownsharp check <path|.sln>` wraps the Roslyn extractor and the Python core (run on system Python) into one dotnet tool install. + + + + + + + + + + + + + diff --git a/frontend/roslyn/OwnSharp.Cli/Program.cs b/frontend/roslyn/OwnSharp.Cli/Program.cs new file mode 100644 index 00000000..56d7a76f --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/Program.cs @@ -0,0 +1,52 @@ +// ownsharp — the single command for alpha gate A (issue #202). +// +// `ownsharp check ` wraps the two existing pipeline stages — +// extractor -> core — into one `dotnet tool install`. This file only does +// verb dispatch; the real work is in CheckCommand.cs / PythonResolver.cs / +// CoreVendor.cs. No analysis logic lives here or anywhere in this project: +// packaging only, per the guardrail in issue #202. + +using OwnSharp.Cli; + +if (args.Length == 0 || args[0] is "-h" or "--help") +{ + Console.WriteLine(HelpText()); + return args.Length == 0 ? 2 : 0; +} + +if (args[0] is "--version") +{ + Console.WriteLine(ToolVersion.Current); + return 0; +} + +if (args[0] != "check") +{ + Console.Error.WriteLine($"ownsharp: unknown command '{args[0]}'"); + Console.Error.WriteLine(HelpText()); + return 2; +} + +return await CheckCommand.RunAsync(args[1..]).ConfigureAwait(false); + +static string HelpText() => """ + ownsharp — find lifetime/resource bugs in C# (Own.NET) + + Usage: + ownsharp check [more paths...] [options] + ownsharp --version + ownsharp --help + + Options (mirrors scripts/own-check.sh): + --format {human|github|msbuild|sarif} finding surface (default: human) + --severity {error|warning} how findings are shown (default: error) + --fail-on-finding exit with the core's code (1 = findings) instead of always 0 + --emit-facts also write the intermediate OwnIR facts.json here + --legacy use the flat name-based local-IDisposable detector + --stats print flow-locals coverage to stderr + --body-throw-edges opt-in: flag body-level (no-try) dispose-not-called-on-throw + + Python: resolved via OWN_PYTHON, else `py -3` (Windows) / `python3` + (elsewhere); must be >=3.11. No auto-install — see the error message if + none is found. + """; diff --git a/frontend/roslyn/OwnSharp.Cli/PythonResolver.cs b/frontend/roslyn/OwnSharp.Cli/PythonResolver.cs new file mode 100644 index 00000000..a59cb9cf --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/PythonResolver.cs @@ -0,0 +1,142 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace OwnSharp.Cli; + +/// Thrown when no usable Python (>=3.11) can be resolved. The message +/// is already the full one-line, actionable, install-and-retry text. +internal sealed class PythonNotFoundException(string message) : Exception(message); + +/// A resolved, launchable Python interpreter (exe + any fixed leading +/// args, e.g. "py" + "-3"). +internal sealed record ResolvedPython(string FileName, IReadOnlyList LeadingArgs); + +/// +/// Resolution order (design decision, issue #202): OWN_PYTHON env var +/// (used exactly as given, no fallback if it doesn't work — an explicit +/// override that fails is a configuration error, not a "keep guessing" case), +/// else the platform default (py -3 on Windows, python3 +/// elsewhere). No auto-download, ever: a miss is a fast, actionable failure. +/// +internal static class PythonResolver +{ + private const int MinMajor = 3; + private const int MinMinor = 11; + + public static ResolvedPython Resolve() + { + var ownPython = Environment.GetEnvironmentVariable("OWN_PYTHON"); + if (!string.IsNullOrWhiteSpace(ownPython)) + { + var candidate = new ResolvedPython(ownPython, Array.Empty()); + if (TryGetVersion(candidate, out var version) && IsSupported(version)) + { + return candidate; + } + throw new PythonNotFoundException( + $"ownsharp: OWN_PYTHON='{ownPython}' did not resolve to Python >={MinMajor}.{MinMinor} " + + $"(found: {version ?? "not runnable"}). {InstallHint()}"); + } + + var defaultCandidate = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? new ResolvedPython("py", ["-3"]) + : new ResolvedPython("python3", Array.Empty()); + + if (TryGetVersion(defaultCandidate, out var defaultVersion) && IsSupported(defaultVersion)) + { + return defaultCandidate; + } + + // A real-world fallback beyond the two names in the design decision: some + // Windows machines have `python` on PATH but no `py` launcher, and some + // Unix setups only ship `python` (already >=3 on any current OS). Trying + // these does not weaken the contract (still >=3.11-or-fail, never an + // auto-install), it just avoids a false negative on an otherwise-fine + // machine. + foreach (var name in new[] { "python3", "python" }) + { + var fallback = new ResolvedPython(name, Array.Empty()); + if (TryGetVersion(fallback, out var version) && IsSupported(version)) + { + return fallback; + } + } + + throw new PythonNotFoundException( + $"ownsharp: no Python >={MinMajor}.{MinMinor} found on PATH. {InstallHint()} " + + "(or set OWN_PYTHON to an interpreter's path)."); + } + + private static bool IsSupported(string? version) + { + if (version is null) + { + return false; + } + var m = Regex.Match(version, @"(\d+)\.(\d+)"); + return m.Success + && int.Parse(m.Groups[1].Value) == MinMajor + && int.Parse(m.Groups[2].Value) >= MinMinor; + } + + private static bool TryGetVersion(ResolvedPython candidate, out string? version) + { + version = null; + try + { + var psi = new ProcessStartInfo(candidate.FileName) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + foreach (var a in candidate.LeadingArgs) + { + psi.ArgumentList.Add(a); + } + psi.ArgumentList.Add("--version"); + using var proc = Process.Start(psi); + if (proc is null) + { + return false; + } + // Python printed --version to stdout since 3.4; stderr covers older + // (never a real target here, but costs nothing to also read). + var stdout = proc.StandardOutput.ReadToEnd(); + var stderr = proc.StandardError.ReadToEnd(); + proc.WaitForExit(); + if (proc.ExitCode != 0) + { + return false; + } + version = string.IsNullOrWhiteSpace(stdout) ? stderr : stdout; + return true; + } + catch (System.ComponentModel.Win32Exception) + { + return false; // the executable itself was not found + } + catch (IOException) + { + return false; + } + } + + private static string InstallHint() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "Install it: winget install Python.Python.3.11"; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "Install it: brew install python@3.11"; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "Install it: sudo apt install -y python3.11 (or your distro's package manager)"; + } + return "Install Python 3.11+ from https://www.python.org/downloads/"; + } +} diff --git a/frontend/roslyn/OwnSharp.Cli/README.md b/frontend/roslyn/OwnSharp.Cli/README.md new file mode 100644 index 00000000..4c1e682e --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/README.md @@ -0,0 +1,88 @@ +# ownsharp — the single command (alpha gate A, issue #202) + +`ownsharp check ` wraps the two existing pipeline stages — +the Roslyn extractor (`OwnSharp.Extractor`, P-013) and the Python core +(`ownlang/`) — into **one `dotnet tool install`**. Same pipeline +[`scripts/own-check.sh`](../../../scripts/own-check.sh) already chains by +hand; this is that, packaged. + +```text +*.cs --[bundled extractor, in a child process]--> facts.json --[vendored core, run on system Python]--> findings +``` + +## Packaging shape (design decision, [issue #202](https://github.com/PhysShell/Own.NET/issues/202)) + +- **The extractor is unmodified**, pulled in via `ProjectReference` — its + build output (dll + `.deps.json`/`.runtimeconfig.json` + Roslyn + dependencies) rides along in this tool's own pack payload because + `PackAsTool` packs the full publish closure. `check` invokes it as a child + process (`dotnet exec /OwnSharp.Extractor.dll ...`). +- **The core is unmodified**, vendored as loose `*.py` content (see the + `.csproj`) and unpacked to `~/.ownsharp/core//` on first run — + never into the analyzed repo. It runs on the machine's own Python; nothing + is embedded, compiled, or downloaded. +- **Python resolution**: `OWN_PYTHON` env var (used exactly as given, no + fallback — an explicit override that fails is a config error, not a + "keep guessing" case), else `py -3` (Windows) / `python3` (elsewhere), + version-checked to be `>=3.11`. No Python found → a fast, one-line, + actionable failure (`winget`/`apt`/`brew`/python.org, per OS) — **never** + an auto-download. +- **Rejected alternatives** (embedding a CPython runtime, self-contained + PyInstaller binaries as the default, waiting for the Rust core, porting the + core to C#) are on the record in the issue; do not re-litigate them here. + +## Build & install locally + +Not published to nuget.org yet (P-013's Non-goals) — build and install from +source: + +```bash +dotnet pack frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj -c Release -o /tmp/ownsharp-nupkg +dotnet tool install --global OwnSharp.Cli --version 0.1.0 --add-source /tmp/ownsharp-nupkg + +ownsharp check MyApp.sln # human output +ownsharp check . --format github --fail-on-finding # PR annotations, non-zero on a leak +ownsharp check . --format sarif > own.sarif # feed github/codeql-action/upload-sarif +``` + +Uninstall/upgrade: `dotnet tool uninstall --global OwnSharp.Cli`, then reinstall +as above (bump `--version` if you rebuilt with a new ``). + +## Flags (mirror `scripts/own-check.sh` 1:1) + +| Flag | Default | | +|---|---|---| +| `--format {human,github,msbuild,sarif}` | `human` | finding surface | +| `--severity {error,warning}` | `error` | how findings are shown | +| `--fail-on-finding` | off | exit with the core's code (1 = findings) instead of always 0 | +| `--emit-facts ` | — | also write the intermediate OwnIR facts.json | +| `--legacy` | off | flat name-based local-`IDisposable` detector instead of `--flow-locals` | +| `--stats` | off | print flow-locals coverage to stderr | +| `--body-throw-edges` | off | opt-in: flag body-level (no-`try`) dispose-not-called-on-throw | + +Exit codes (same contract as `own-check.sh`/`.ps1`): the extractor stage's own +exit code propagates on a hard failure there; otherwise `0` clean / `1` +findings (only surfaced when `--fail-on-finding`) / `>=2` a core hard error +(bad facts, a drifted contract) always propagates; `3` is `ownsharp`'s own — +no usable Python was found. + +## Guardrails this project honors (no behaviour change, packaging only) + +- **No changes to `OwnSharp.Extractor`** — it is referenced, not edited. +- **No changes to `ownlang/`** — vendored byte-identical; "one checker" holds + literally, since the exact same core source renders every verdict. +- **`scripts/own-check.sh`/`.ps1` and `action.yml` are untouched** and keep + working exactly as before — this tool is a third surface alongside them, not + a replacement (P-013 §Scope). + +## CI proof + +`ownsharp-cli-smoke` in `.github/workflows/ci.yml` (matrix: `ubuntu-latest` + +`windows-latest`) proves, on a clean runner: pack → `dotnet tool install +--global` → `ownsharp check` finds a real leak (`--fail-on-finding` exits 1, +`OWN001` in the output) → the timed install-to-findings window stays under a +regression ceiling → the no-Python path fails fast with the actionable +message. Both platforms matter here specifically, not just "more coverage": a +`dotnet tool` shim is a native apphost on Windows and a shell script on Unix — +genuinely different process-launch mechanics, so ubuntu-only would not have +proven the Windows path. diff --git a/frontend/roslyn/OwnSharp.Cli/ToolVersion.cs b/frontend/roslyn/OwnSharp.Cli/ToolVersion.cs new file mode 100644 index 00000000..b6a593b4 --- /dev/null +++ b/frontend/roslyn/OwnSharp.Cli/ToolVersion.cs @@ -0,0 +1,14 @@ +using System.Reflection; + +namespace OwnSharp.Cli; + +/// +/// The running tool's own version — doubles as the vendored-core cache key +/// (~/.ownsharp/core/<version>/), so a core mismatch between two +/// installed tool versions can never share a cache directory. +/// +internal static class ToolVersion +{ + public static string Current { get; } = + typeof(ToolVersion).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"; +} diff --git a/frontend/roslyn/README.md b/frontend/roslyn/README.md index c68cfd33..12118e8e 100644 --- a/frontend/roslyn/README.md +++ b/frontend/roslyn/README.md @@ -102,7 +102,10 @@ consumer repo adds (see `examples/ci/own-check.yml`): **`dotnet tool`.** The extractor alone is packable (`dotnet pack` → `dotnet tool install --global OwnSharp.Extractor` → `ownsharp-extract`). It emits facts only; the verdict still comes from the -Python core, so the script/Action are the complete product. +Python core, so the script/Action are the complete product. For the single +command that wraps both stages into one install, see +[`OwnSharp.Cli`](OwnSharp.Cli/README.md) (alpha gate A, issue #202) — +`ownsharp check `. Why CI/CLI and not a native Roslyn analyzer: a true `DiagnosticAnalyzer` runs in-process and would force a *second* checker in C# (or shelling out to Python From d01fb544eefff00e59c69687c0d88c2f8ade3cbc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 06:19:03 +0000 Subject: [PATCH 2/2] fix: ownsharp check looks for ownsharp-extract.dll, not OwnSharp.Extractor.dll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OwnSharp.Extractor.csproj sets ownsharp-extract, so the built/packed DLL is ownsharp-extract.dll — the project/package name is not the assembly name. CheckCommand.RunExtractorAsync was checking for the wrong filename, so every check invocation reported a corrupt install and exited before producing findings. Also fixed the two cosmetic comment references (csproj, README) to match. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01D6Naf8CcezjCbikueKdeYv --- frontend/roslyn/OwnSharp.Cli/CheckCommand.cs | 2 +- frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj | 2 +- frontend/roslyn/OwnSharp.Cli/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs b/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs index 89c3716a..6bcbbfe3 100644 --- a/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs +++ b/frontend/roslyn/OwnSharp.Cli/CheckCommand.cs @@ -145,7 +145,7 @@ private static string RequireValue(string[] args, ref int i, string flag) private static async Task RunExtractorAsync( IReadOnlyList paths, string factsPath, bool legacy, bool stats, bool bodyThrowEdges) { - var extractorDll = Path.Combine(AppContext.BaseDirectory, "OwnSharp.Extractor.dll"); + var extractorDll = Path.Combine(AppContext.BaseDirectory, "ownsharp-extract.dll"); if (!File.Exists(extractorDll)) { Console.Error.WriteLine( diff --git a/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj index 916fc77b..da73dbbe 100644 --- a/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj +++ b/frontend/roslyn/OwnSharp.Cli/OwnSharp.Cli.csproj @@ -18,7 +18,7 @@ .deps.json/.runtimeconfig.json + Roslyn deps) rides along in this tool's own pack payload because PackAsTool packs this project's full publish closure. At runtime `check` invokes it as a child - process ("dotnet exec (bundled)/OwnSharp.Extractor.dll ..."), + process ("dotnet exec (bundled)/ownsharp-extract.dll ..."), the same subprocess shape own-check.sh already uses, just against a bundled binary instead of running the extractor from source. - The Python core (ownlang/, zero-dependency pure Python, see diff --git a/frontend/roslyn/OwnSharp.Cli/README.md b/frontend/roslyn/OwnSharp.Cli/README.md index 4c1e682e..7c1deaf2 100644 --- a/frontend/roslyn/OwnSharp.Cli/README.md +++ b/frontend/roslyn/OwnSharp.Cli/README.md @@ -16,7 +16,7 @@ hand; this is that, packaged. build output (dll + `.deps.json`/`.runtimeconfig.json` + Roslyn dependencies) rides along in this tool's own pack payload because `PackAsTool` packs the full publish closure. `check` invokes it as a child - process (`dotnet exec /OwnSharp.Extractor.dll ...`). + process (`dotnet exec /ownsharp-extract.dll ...`). - **The core is unmodified**, vendored as loose `*.py` content (see the `.csproj`) and unpacked to `~/.ownsharp/core//` on first run — never into the analyzed repo. It runs on the machine's own Python; nothing