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