From 6ef2ead00198efdb4a05164a515568dbe1e17bf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 04:18:27 +0000 Subject: [PATCH 1/2] feat(extractor): --help + auto-derive project bin/ references (CLI polish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers the remaining "discoverable CLI" value from the roslyn-tools note, without adopting System.CommandLine (a churn-prone preview dependency; the extractor builds only in CI here, so a blind framework restructure of the 3,400-line entry point is the risky path — the hand-rolled surface is the same UX at a fraction of the risk): - `-h`/`--help`: a full usage block (commands, inputs, options), plus a --help pointer on the no-input error. - `--ref-dir`-from-project-`bin` auto-derivation: for a .csproj/.sln input, the project's built bin/ output is auto-added to the reference set (the --ref-dir you'd otherwise pass by hand), so a built/restored project's third-party events bind to real symbols instead of OWN050. `--no-project-refs` opts out; an unbuilt project contributes nothing (no crash, degrades to OWN050). See ProjectBinDirs. CI: --help renders the option list; --no-project-refs is accepted and (no bin/ on the sample) yields identical facts. Docs (note + README) updated, incl. the explicit rationale for not taking the System.CommandLine dependency. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KzA6KGQVRzP52tBEKwJ3SW --- .github/workflows/ci.yml | 12 +++ docs/notes/roslyn-tools-and-cli.md | 21 ++++- frontend/roslyn/OwnSharp.Extractor/Program.cs | 83 +++++++++++++++++++ frontend/roslyn/README.md | 7 ++ 4 files changed, 120 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93126c43..6aca4cd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1004,6 +1004,18 @@ jobs: <(jq -S . "$RUNNER_TEMP/proj-facts-verb.json") \ || { echo "FAIL: 'extract --out' verb form disagrees with the bare form"; exit 1; } echo "OK: .csproj input resolves to its source set and feeds the core identically (bare == --project == 'extract --out', fact-level parity)" + # --help renders the discoverable usage (commands/inputs/options) and exits 0. + dotnet run --project frontend/roslyn/OwnSharp.Extractor -- --help > "$RUNNER_TEMP/help.txt" + grep -q "Usage:" "$RUNNER_TEMP/help.txt" && grep -q -- "--no-project-refs" "$RUNNER_TEMP/help.txt" \ + || { echo "FAIL: --help did not render the usage/options"; exit 1; } + # --no-project-refs is accepted and (with no bin/ on the sample) yields identical facts. + dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ + --no-project-refs --project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \ + --out "$RUNNER_TEMP/proj-facts-norefs.json" + diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \ + <(jq -S . "$RUNNER_TEMP/proj-facts-norefs.json") \ + || { echo "FAIL: --no-project-refs changed the facts for an unbuilt sample project"; exit 1; } + echo "OK: --help renders the option list; --no-project-refs is accepted" # explain: the diagnostic-catalogue CLI surface lives in the core (one checker). Smoke # it end-to-end — explain a code, and harvest+explain every code in a real findings file. - name: explain command (code + --json harvest) diff --git a/docs/notes/roslyn-tools-and-cli.md b/docs/notes/roslyn-tools-and-cli.md index 4adc7f81..5127feba 100644 --- a/docs/notes/roslyn-tools-and-cli.md +++ b/docs/notes/roslyn-tools-and-cli.md @@ -143,9 +143,24 @@ output — not from `facts.ownir.json`, which carries extractor facts, no codes. why it fires, and how to fix it; `--json` harvests every code from a findings/SARIF file so you can explain exactly what a run produced. -A `System.CommandLine` migration of the C# tool (auto `--help`, validation) and -`--ref-dir`-from-project-`bin` auto-derivation remain the next polish — deferred over -a blind framework swap, since the extractor builds only in CI here. +### CLI polish (landed) — and why not `System.CommandLine` + +Two pieces of the "discoverable CLI" value landed: + +- **`-h` / `--help`** — a full hand-rendered usage block (commands, inputs, options), plus + a `--help` pointer on the no-input error. +- **`--ref-dir`-from-project-`bin` auto-derivation** — for a `.csproj`/`.sln` input, the + project's built `bin/` output is auto-added to the reference set (the `--ref-dir` you'd + otherwise pass by hand), so a built/restored project's third-party events bind to real + symbols instead of surfacing as OWN050. `--no-project-refs` opts out; an unbuilt project + contributes nothing (no crash, just degrades to OWN050). See `ProjectBinDirs` in `Program.cs`. + +We **did not** adopt the `System.CommandLine` package itself. It buys auto-`--help`, +validation, and completions — but it is a churn-prone *preview* dependency, and the migration +is a large restructure of a 3,400-line entry point that **builds only in CI here** (no local +`dotnet`). The hand-rolled help/validation delivers the same user-facing surface at a fraction +of the risk; the framework swap stays available if tab-completion / generated help ever earns +its keep. ## Earlier next-PR sketch (kept for the record) diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 2f19b5ee..9fe99772 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -65,6 +65,12 @@ // flow-analysed vs honestly skipped for an unmodelled construct) and stamp the // same counts into the facts JSON. Turns "0 findings" into "clean vs didn't-reach". bool reportStats = false; +// --no-project-refs: opt out of the automatic reference derivation below. By default, when an +// input is a `.csproj`/`.sln`, the project's built `bin/` output (if present) is auto-added to +// the reference set — the scriptable `--ref-dir` you'd otherwise pass by hand — so third-party +// events on a restored/built project bind to real symbols instead of surfacing as OWN050. This +// flag turns that off (e.g. to measure raw Tier-A coverage, or when bin/ is stale). +bool noProjectRefs = false; // --body-throw-edges (opt-in, P-016 throw tier): also treat an ESCAPING body-level may-throw // call/`new` (not only those inside a `try`) as a dispose-not-called-on-throw point — CodeQL // cs/dispose-not-called-on-throw parity. OFF by default: it is the CA2000 firehose (flags even @@ -82,6 +88,43 @@ // own-check orchestrator, `explain` is `python -m ownlang explain`; the C# tool is not a // second checker.) A different first token (a path/flag) is left untouched as input. var args0 = args.Length > 0 && args[0] == "extract" ? args[1..] : args; +// `-h` / `--help`: print the full usage (commands, inputs, options) and exit 0. A discoverable +// CLI is the value the roslyn-tools `System.CommandLine` shape buys; we render it by hand (no +// preview dependency) rather than adopt the framework — the surface is small and stable. +const string UsageText = """ +ownsharp-extract — emit OwnIR leak facts from C# for the Own.NET core to check. + +Usage: + ownsharp-extract [extract] ... [options] + +`extract` is an optional leading verb (the tool's one job; the bare form is the default). +The sibling verbs live elsewhere by design — `check` is scripts/own-check.sh (extractor + +core), `explain` is `python -m ownlang explain OWN001`. One checker: this tool only emits facts. + +Inputs (any mix; positional, or via --project/--solution): + file.cs a single C# file + dir a directory, walked recursively (skips bin/obj, generated, vendor) + App.csproj a project — resolved to its source set (no MSBuild evaluation) + App.sln a solution — fans out over its member projects + +Options: + -o, --out FILE write the OwnIR facts JSON to FILE (default: stdout) + --project FILE add a .csproj input (flag twin of the positional form) + --solution FILE add a .sln input + --ref-dir DIR add DIR's DLLs (recursively) to the reference set, so third-party + events bind to real symbols instead of OWN050 (repeatable) + --no-project-refs don't auto-add a .csproj/.sln project's bin/ output to the references + --no-event-leaks skip event-subscription detection (run only disposable/pool detectors) + --flow-locals path-sensitive flow analysis of non-escaping local IDisposables + --stats print flow-locals coverage (requires --flow-locals) + --body-throw-edges treat escaping body-level may-throw as a dispose-on-throw point (needs --flow-locals) + -h, --help show this help and exit +"""; +if (args0.Contains("-h") || args0.Contains("--help")) +{ + Console.WriteLine(UsageText); + return 0; +} for (int i = 0; i < args0.Length; i++) { // `--out FILE` is the long-form twin of `-o FILE` (the advertised `extract --out` UX). @@ -92,6 +135,7 @@ // the positional form keeps the command unambiguous next to dotnet's own `run --project`. else if ((args0[i] == "--project" || args0[i] == "--solution") && i + 1 < args0.Length) rawInputs.Add(args0[++i]); else if (args0[i] == "--ref-dir" && i + 1 < args0.Length) refDirs.Add(args0[++i]); + else if (args0[i] == "--no-project-refs") noProjectRefs = true; else if (args0[i] == "--no-event-leaks") emitEvents = false; else if (args0[i] == "--flow-locals") flowLocals = true; else if (args0[i] == "--body-throw-edges") BodyThrowEdges = true; @@ -102,6 +146,7 @@ if (rawInputs.Count == 0) { Console.Error.WriteLine("usage: ownsharp-extract [extract] [...] [-o|--out facts.json] [--ref-dir ]"); + Console.Error.WriteLine(" ownsharp-extract --help for the full option list"); return 2; } @@ -322,6 +367,33 @@ static List SolutionProjects(string sln) return projects; } +// The built `bin/` output directory of each `.csproj` (or each member project of a `.sln`) among +// the inputs — the references to auto-derive (P-014 Tier B convenience). Returns only directories +// that exist, deduped: an unbuilt project contributes nothing (and its third-party events degrade +// to OWN050, never a crash). The caller adds these to the reference set exactly like a hand-passed +// `--ref-dir`, so a built/restored project's third-party events bind without one. +static List ProjectBinDirs(IEnumerable rawInputs) +{ + var bins = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + void AddFor(string csproj) + { + var full = Path.GetFullPath(csproj); + if (!File.Exists(full)) return; // missing project: nothing to derive + var dir = Path.GetDirectoryName(full); + if (dir is null) return; + var bin = Path.Combine(dir, "bin"); + if (Directory.Exists(bin) && seen.Add(bin)) bins.Add(bin); + } + foreach (var p in rawInputs) + { + if (p.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) AddFor(p); + else if (p.EndsWith(".sln", StringComparison.OrdinalIgnoreCase)) + foreach (var proj in SolutionProjects(p)) AddFor(proj); + } + return bins; +} + // Expand inputs into their .cs files. A directory is walked recursively; a `.csproj`/`.sln` // is resolved to its source set (so `ownsharp-extract App.csproj` / `App.sln` works, the // CLI-first project input borrowed from the roslyn-tools tooling shape); an explicit file @@ -366,6 +438,17 @@ static IEnumerable Expand(IEnumerable roots) static string Rel(string path) => Path.GetRelativePath(Directory.GetCurrentDirectory(), path).Replace('\\', '/'); +// Auto-derive project references (unless --no-project-refs): for a .csproj/.sln input, add its +// built bin/ output to the reference set so third-party events bind without a hand-passed --ref-dir. +// Appended to refDirs before the reference set is built; the recursive, first-name-wins --ref-dir +// loader handles it from there (a framework/TPA simple-name already loaded is never double-added). +if (!noProjectRefs) + foreach (var bin in ProjectBinDirs(rawInputs)) + { + refDirs.Add(bin); + Console.Error.WriteLine($"extractor: auto-referencing project output {bin} (--no-project-refs to disable)"); + } + var inputs = Expand(rawInputs).Distinct().ToList(); static bool IsHandler(ExpressionSyntax rhs) => diff --git a/frontend/roslyn/README.md b/frontend/roslyn/README.md index 5cb753af..5d517384 100644 --- a/frontend/roslyn/README.md +++ b/frontend/roslyn/README.md @@ -63,6 +63,13 @@ graph) is the `ProjectDependencies`-category work parked for DI/solution scans, the v0 leak extractor — see [`docs/notes/roslyn-tools-and-cli.md`](../../docs/notes/roslyn-tools-and-cli.md). +When the input is a `.csproj`/`.sln`, the project's built `bin/` output is +**auto-added to the reference set** (the `--ref-dir` you'd otherwise pass by hand), +so a built/restored project's third-party events (WPF/DevExpress) bind to real +symbols instead of surfacing as OWN050. `--no-project-refs` opts out; an unbuilt +project just contributes nothing. Run `ownsharp-extract --help` for the full option +list. + ## Use it on a real repo / in CI (P-013) The two stages are chained by one orchestrator script, so you don't run them by From ddd6bcf4359d800413585116bd5b43ec7c005f6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 04:22:54 +0000 Subject: [PATCH 2/2] fix(ci/docs): guard --no-project-refs unbuilt precondition; consistent --help invocation (CodeRabbit) - ci.yml: the --no-project-refs parity check assumed the sample is unbuilt but never asserted it. Add an explicit `[ ! -d .../bin ]` guard so a future step that builds the sample fails with a clear message, not a confusing facts diff. - README: the help example used the bare `ownsharp-extract` command while that section documents source-tree `dotnet run` usage; switch to `dotnet run --project OwnSharp.Extractor -- --help` for consistency. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KzA6KGQVRzP52tBEKwJ3SW --- .github/workflows/ci.yml | 4 ++++ frontend/roslyn/README.md | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aca4cd6..7c4ed1c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1009,6 +1009,10 @@ jobs: grep -q "Usage:" "$RUNNER_TEMP/help.txt" && grep -q -- "--no-project-refs" "$RUNNER_TEMP/help.txt" \ || { echo "FAIL: --help did not render the usage/options"; exit 1; } # --no-project-refs is accepted and (with no bin/ on the sample) yields identical facts. + # Guard the precondition: the parity below only holds while the sample is unbuilt, so a + # future step that builds it fails here with a clear message, not a confusing facts diff. + [ ! -d frontend/roslyn/project-input-sample/bin ] \ + || { echo "FAIL: sample project must be unbuilt for the --no-project-refs parity check"; exit 1; } dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ --no-project-refs --project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \ --out "$RUNNER_TEMP/proj-facts-norefs.json" diff --git a/frontend/roslyn/README.md b/frontend/roslyn/README.md index 5d517384..c68cfd33 100644 --- a/frontend/roslyn/README.md +++ b/frontend/roslyn/README.md @@ -67,8 +67,8 @@ When the input is a `.csproj`/`.sln`, the project's built `bin/` output is **auto-added to the reference set** (the `--ref-dir` you'd otherwise pass by hand), so a built/restored project's third-party events (WPF/DevExpress) bind to real symbols instead of surfacing as OWN050. `--no-project-refs` opts out; an unbuilt -project just contributes nothing. Run `ownsharp-extract --help` for the full option -list. +project just contributes nothing. Run `dotnet run --project OwnSharp.Extractor -- --help` +for the full option list. ## Use it on a real repo / in CI (P-013)