diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93126c43..7c4ed1c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1004,6 +1004,22 @@ 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. + # 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 <(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..c68cfd33 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 `dotnet run --project OwnSharp.Extractor -- --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