diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 176011d5..3f3bf7c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -945,6 +945,33 @@ jobs: fi done echo "OK: WinForms modeless Form.Show() = call-site release (framework-owned); conditional show leaks on the no-show path; modal ShowDialog() leak caught; disposed modal silent" + # Project-file input (the CLI-first project/solution resolution borrowed from the + # roslyn-tools tooling shape): point the extractor at a .csproj instead of a file + # list and assert the same event leak surfaces. Proves ProjectCsFiles resolves the + # SDK-style project to its source set and feeds the core identically to the per-file + # path. Both the positional and the `--project` flag forms are exercised. + - name: Project-file input (.csproj -> source set -> core) + run: | + dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ + frontend/roslyn/project-input-sample/ProjectInputSample.csproj \ + -o "$RUNNER_TEMP/proj-facts.json" + out=$(python -m ownlang ownir "$RUNNER_TEMP/proj-facts.json" || true) + echo "$out" + echo "$out" | grep -q "CustomerSubscription.cs" \ + || { echo "FAIL: .csproj input did not resolve CustomerSubscription.cs"; exit 1; } + echo "$out" | grep -qE "CustomerSubscription\.cs:[0-9]+:.*\[OWN001\]" \ + || { echo "FAIL: expected OWN001 via .csproj input"; exit 1; } + # the `--project` flag form must resolve to the same source set as the positional form. + # Assert parity at the FACT boundary (canonicalized OwnIR), not after the Python core — + # diffing rendered diagnostics could pass even if the two paths emit different facts the + # core happens to collapse to the same warnings (CodeRabbit). + dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ + --project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \ + -o "$RUNNER_TEMP/proj-facts-flag.json" + diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \ + <(jq -S . "$RUNNER_TEMP/proj-facts-flag.json") \ + || { echo "FAIL: --project flag and positional .csproj emit different OwnIR facts"; exit 1; } + echo "OK: .csproj input resolves to its source set and feeds the core identically (positional == --project, fact-level parity)" # The distribution surface (Уровень 1): the own-check.sh orchestrator walks a # directory of real C# and prints findings in the host-parseable formats the diff --git a/docs/notes/roslyn-tools-and-cli.md b/docs/notes/roslyn-tools-and-cli.md index 4df66838..15efb273 100644 --- a/docs/notes/roslyn-tools-and-cli.md +++ b/docs/notes/roslyn-tools-and-cli.md @@ -93,20 +93,42 @@ frontend (generics / async / interprocedural dataflow) is explicitly rejected as It must **not** decide ownership, join states at merges, reason about borrows, or otherwise produce an alternative truth. One checker; the C# side feeds it. -## Next PR (concrete, no scope creep) +## Project/solution input (landed) -`OwnSharp.Extractor` CLI: turn a `.csproj` into `facts.ownir.json`, then firm up -the contract and a golden until it's presentable. +The first borrowed plyushka is real: the extractor now accepts a `.csproj` or +`.sln` as input (positional or via `--project` / `--solution`), not just a file +list — the CLI-first project resolution the mature tooling repos start from. ```bash -dotnet run --project frontend/roslyn/OwnSharp.Extractor \ - --project samples/WpfLeakSample/WpfLeakSample.csproj \ - --out artifacts/facts.ownir.json +dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ + frontend/roslyn/project-input-sample/ProjectInputSample.csproj \ + -o artifacts/facts.ownir.json python -m ownlang ownir artifacts/facts.ownir.json -# -> CustomerViewModel.cs:9: error: [OWN001] event 'bus.CustomerChanged' +# -> CustomerSubscription.cs:11: warning: [OWN001] event 'bus.CustomerChanged' # is subscribed (handler 'OnCustomerChanged') but never unsubscribed ``` +Resolution is **dependency-free** (text/XML, no MSBuild evaluation): a `.csproj` +maps to its directory's `*.cs` the SDK default-compile-items way plus concrete +linked `` files; a `.sln` fans out over its member projects (see +`ProjectCsFiles` / `SolutionProjects` in `Program.cs`). It deliberately stops +short of the full project/package/reference graph — that is the +`ProjectDependencies`-category work, parked for DI/solution scans. A +`wpf-extractor` CI step pins the `.csproj` path to a golden (positional == +`--project`). + +## Next PR (concrete, no scope creep) + +Firm up the rest of the advertised CLI: `extract` / `check` / `explain` +subcommands (`System.CommandLine`), and `--ref-dir`-from-project-`bin` +auto-derivation once `ProjectDependencies`-style graph reading lands. + +```bash +ownsharp extract --project App.csproj --out facts.ownir.json +ownsharp check --solution App.sln +ownsharp explain OWN001 --json diagnostic.json +``` + A facts record carries enough to place and explain the finding — kind, resource, owner, subject, location, and domain-neutral metadata: diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 8282df99..5894c217 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -16,14 +16,20 @@ // `.Stop()` call. The IDisposable/pool/local detectors remain syntactic for now // (P-014 rollout: the event fact goes type-aware first). // -// Usage: ownsharp-extract [more ...] [-o facts.json] +// Usage: ownsharp-extract [more ...] [-o facts.json] +// ownsharp-extract --project App.csproj (flag twin of the positional form) +// ownsharp-extract --solution App.sln // -// Inputs may be .cs files or directories. A directory is walked recursively for -// *.cs, skipping build output (bin/obj), VCS/vendor dirs (.git, node_modules) -// and generated files (*.g.cs, *.Designer.cs) — so you can point it at a whole -// repo (this is what the `own-check` script / GitHub Action do). - +// Inputs may be .cs files, directories, a .csproj, or a .sln. A directory is walked +// recursively for *.cs, skipping build output (bin/obj), VCS/vendor dirs (.git, +// node_modules) and generated files (*.g.cs, *.Designer.cs) — so you can point it at a +// whole repo (this is what the `own-check` script / GitHub Action do). A .csproj resolves +// to its source set (SDK-style directory scan + concrete linked files; no full +// MSBuild evaluation — see ProjectCsFiles); a .sln fans out over its member projects. + +using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Xml.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -67,6 +73,11 @@ for (int i = 0; i < args.Length; i++) { if (args[i] == "-o" && i + 1 < args.Length) outPath = args[++i]; + // `--project ` / `--solution `: the explicit-flag twin of passing the + // project/solution as a positional input (both resolve through Expand). The flag form matches + // the advertised `ownsharp extract --project ...` UX borrowed from the roslyn-tools CLI shape; + // the positional form keeps the command unambiguous next to dotnet's own `run --project`. + else if ((args[i] == "--project" || args[i] == "--solution") && i + 1 < args.Length) rawInputs.Add(args[++i]); else if (args[i] == "--ref-dir" && i + 1 < args.Length) refDirs.Add(args[++i]); else if (args[i] == "--no-event-leaks") emitEvents = false; else if (args[i] == "--flow-locals") flowLocals = true; @@ -77,7 +88,7 @@ if (rawInputs.Count == 0) { - Console.Error.WriteLine("usage: ownsharp-extract [...] [-o facts.json] [--ref-dir ]"); + Console.Error.WriteLine("usage: ownsharp-extract [...] [-o facts.json] [--ref-dir ]"); return 2; } @@ -113,9 +124,196 @@ static bool IsSkipped(string path) return IsGenerated(path); } -// Expand directories into their .cs files; pass explicit files through as-is. -// IgnoreInaccessible tolerates an unreadable subdir mid-walk (otherwise the -// whole scan would abort with an unhandled exception on a locked directory). +// Translate an MSBuild item spec (a path, optionally with `*`, `?`, `**` globs and either +// separator) into a predicate over a full file path, evaluated RELATIVE to the project dir. +// Enough for the common `` / `` forms (a concrete path, `*.cs`, +// `**/*.cs`, `Folder/**`) without a full MSBuild glob engine: `**` matches across directories, +// `*` / `?` within a single path segment. Match is case-insensitive (MSBuild globbing is). +static Func SpecMatcher(string spec, string dir) +{ + var glob = spec.Replace('\\', '/').Trim(); + var rx = new StringBuilder("^"); + for (int i = 0; i < glob.Length; i++) + { + var c = glob[i]; + if (c == '*') + { + if (i + 1 < glob.Length && glob[i + 1] == '*') // `**` -> any chars, including '/' + { + rx.Append(".*"); + i++; + if (i + 1 < glob.Length && glob[i + 1] == '/') i++; // swallow the slash after `**` + } + else rx.Append("[^/]*"); // `*` -> within one path segment + } + else if (c == '?') rx.Append("[^/]"); + else rx.Append(Regex.Escape(c.ToString())); + } + rx.Append('$'); + // `.csproj` content is untrusted in CI, and adjacent wildcards (`**/**`, `**/*`) translate to + // ambiguous adjacent quantifiers the backtracking .NET engine can blow up on (ReDoS). Bound the + // match with a timeout, and treat a timeout as "no match" rather than letting it crash the run. + var regex = new Regex(rx.ToString(), RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1)); + return path => + { + var rel = Path.GetRelativePath(dir, path).Replace('\\', '/'); + try { return regex.IsMatch(rel); } + catch (RegexMatchTimeoutException) + { + Console.Error.WriteLine($"ownsharp-extract: glob match timed out for '{spec}' on '{rel}'; treated as no match"); + return false; + } + }; +} + +// Resolve a `.csproj` to its C# source set. Doing this the MSBuild way needs a full +// project evaluation (and the `Microsoft.CodeAnalysis.Workspaces.MSBuild` + MSBuildLocator +// dependency that P-014 / the "ProjectDependencies as a category" note deliberately parks +// for the DI/solution-graph work — not for the v0 leak extractor). The pragmatic resolution +// that covers the SDK-style common case WITHOUT that baggage: +// - candidates = every in-tree *.cs (SDK default-compile-items), with bin/obj and generated +// files already excluded by IsSkipped; +// - but honour the project's explicit compile set: `false` turns the +// default OFF (then only files an explicit `` selects are kept), and +// `` subtracts excluded files — so `.csproj` input does not emit findings +// from files the project does not actually compile (CodeRabbit: a source-set mismatch, not a +// harmless over-approximation); +// - plus any concrete linked `` that points OUTSIDE the tree. +// Include/Remove globs are matched by SpecMatcher (not a full MSBuild engine, but enough for the +// common forms). Builds a list rather than yielding so the XML read can sit in a try/catch (an +// iterator may not yield from inside one); a malformed project degrades to the plain directory scan. +static List ProjectCsFiles(string csproj, EnumerationOptions opts) +{ + var full = Path.GetFullPath(csproj); + var result = new List(); + // A missing project file must NOT degrade to "scan its parent directory": a typo'd + // `--project src/Missing.csproj` would otherwise analyse all of src/**/*.cs (an + // unintended source set), or throw if the parent is absent too. Skip the bad input with + // a warning — the directory-scan fallback below is only for a PRESENT-but-malformed + // project (whose items we could not read), never an absent one. (Codex P2.) + if (!File.Exists(full)) + { + Console.Error.WriteLine($"ownsharp-extract: project not found: {csproj}"); + return result; + } + var dir = Path.GetDirectoryName(full) ?? "."; + + XDocument? doc = null; + try { doc = XDocument.Load(full); } + catch (Exception ex) + { + Console.Error.WriteLine( + $"ownsharp-extract: {csproj}: reading items failed ({ex.Message}); used directory scan only"); + } + var elements = (doc?.Descendants() ?? Enumerable.Empty()).ToList(); + + // `false` (last value wins, as MSBuild evaluates top-to-bottom) + // turns off the implicit "every *.cs is compiled" default. + var defaultItems = elements + .Where(e => e.Name.LocalName == "EnableDefaultCompileItems") + .Select(e => e.Value.Trim()) + .LastOrDefault(); + var defaultCompile = !string.Equals(defaultItems, "false", StringComparison.OrdinalIgnoreCase); + + var includes = elements.Where(e => e.Name.LocalName == "Compile") + .Select(e => e.Attribute("Include")?.Value) + .Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).ToList(); + var removes = elements.Where(e => e.Name.LocalName == "Compile") + .Select(e => e.Attribute("Remove")?.Value) + .Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v!).ToList(); + + var candidates = Directory.EnumerateFiles(dir, "*.cs", opts).Where(f => !IsSkipped(f)).ToList(); + if (defaultCompile) + result.AddRange(candidates); + else + { + // Explicit-list project: keep only in-tree files an `` selects. + var incMatch = includes.Select(s => SpecMatcher(s, dir)).ToList(); + result.AddRange(candidates.Where(f => incMatch.Any(m => m(f)))); + } + + // Concrete linked `` that points OUTSIDE the project tree, in either mode. + var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar) ? dir : dir + Path.DirectorySeparatorChar; + foreach (var inc in includes) + { + if (inc.IndexOfAny(new[] { '*', '?' }) >= 0) continue; // a glob — handled above / by the scan + var path = Path.GetFullPath(Path.Combine(dir, inc.Replace('\\', Path.DirectorySeparatorChar))); + if (path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase) + && File.Exists(path) && !IsSkipped(path) + && !path.StartsWith(dirPrefix, StringComparison.Ordinal)) // in-tree links already added + result.Add(path); + } + + // Honour ``: drop excluded files (concrete or glob, relative to the dir). + if (removes.Count > 0) + { + var rmMatch = removes.Select(s => SpecMatcher(s, dir)).ToList(); + result.RemoveAll(f => rmMatch.Any(m => m(f))); + } + + return result.Distinct().ToList(); +} + +// Extract the double-quoted fields from a solution `Project(...)` line tail, in order. The fields +// are `"Name", "relpath", "{guid}"`; splitting on quotes (not raw commas) means a comma INSIDE a +// quoted name or path no longer misreads the line — CodeRabbit flagged the naive `Split(',')`, +// which could skip a valid project or resolve the wrong path. (`.sln` does not use `""` escaping.) +static List QuotedFields(string s) +{ + var fields = new List(); + int i = 0; + while (true) + { + int a = s.IndexOf('"', i); + if (a < 0) break; + int b = s.IndexOf('"', a + 1); + if (b < 0) break; + fields.Add(s.Substring(a + 1, b - a - 1)); + i = b + 1; + } + return fields; +} + +// Resolve a classic `.sln` to its member `.csproj` paths. The solution file lists each project +// as `Project("{type-guid}") = "Name", "rel\path.csproj", "{guid}"`; solution folders use the +// same line shape but their path is not a `.csproj`, so filtering on the extension drops them. +// Text parsing (no MSBuild) keeps this dependency-free; a missing member is reported and skipped, +// never fatal — a solution-wide scan should survive one stale project reference. +static List SolutionProjects(string sln) +{ + var dir = Path.GetDirectoryName(Path.GetFullPath(sln)) ?? "."; + var projects = new List(); + string[] lines; + try { lines = File.ReadAllLines(sln); } + catch (Exception ex) + { + Console.Error.WriteLine($"ownsharp-extract: cannot read solution {sln} ({ex.Message})"); + return projects; + } + foreach (var line in lines) + { + var t = line.TrimStart(); + if (!t.StartsWith("Project(", StringComparison.Ordinal)) continue; + var eq = t.IndexOf('='); + if (eq < 0) continue; + // The path is the SECOND double-quoted field after '=' ("Name", "relpath", "{guid}"); + // quote-aware extraction tolerates a comma inside the name or path. + var fields = QuotedFields(t.Substring(eq + 1)); + if (fields.Count < 2) continue; + var rel = fields[1].Trim(); + if (!rel.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) continue; + var path = Path.GetFullPath(Path.Combine(dir, rel.Replace('\\', Path.DirectorySeparatorChar))); + if (File.Exists(path)) projects.Add(path); + else Console.Error.WriteLine($"ownsharp-extract: {sln}: project not found: {rel}"); + } + return projects; +} + +// 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 +// passes through as-is. IgnoreInaccessible tolerates an unreadable subdir mid-walk (otherwise +// the whole scan would abort with an unhandled exception on a locked directory). static IEnumerable Expand(IEnumerable roots) { var opts = new EnumerationOptions @@ -131,6 +329,17 @@ static IEnumerable Expand(IEnumerable roots) if (!IsSkipped(f)) yield return f; } + else if (p.EndsWith(".sln", StringComparison.OrdinalIgnoreCase)) + { + foreach (var proj in SolutionProjects(p)) + foreach (var f in ProjectCsFiles(proj, opts)) + yield return f; + } + else if (p.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + { + foreach (var f in ProjectCsFiles(p, opts)) + yield return f; + } else { yield return p; diff --git a/frontend/roslyn/README.md b/frontend/roslyn/README.md index 5dc5b4f1..c09716cb 100644 --- a/frontend/roslyn/README.md +++ b/frontend/roslyn/README.md @@ -28,6 +28,31 @@ python -m ownlang ownir facts.json # (OrdersViewModel unsubscribes in Dispose -> nothing reported) ``` +### Inputs: files, directories, `.csproj`, `.sln` + +Inputs may be `.cs` files, directories (walked recursively, skipping `bin`/`obj`/ +generated), a **`.csproj`**, or a **`.sln`** — so you can hand the extractor a +project or solution the way the borrowed roslyn-tools CLI shape advertises: + +```bash +dotnet run --project OwnSharp.Extractor -- App.csproj -o facts.json # positional +dotnet run --project OwnSharp.Extractor -- --project App.csproj -o facts.json +dotnet run --project OwnSharp.Extractor -- --solution App.sln -o facts.json +``` + +A `.csproj` resolves to its source set by scanning the project's directory for +`*.cs` (the SDK default-compile-items behaviour) plus any concrete linked +`` outside the project tree — while honouring +the project's explicit compile set: `false` switches to +include-driven, and `` subtracts excluded files (so the +extractor doesn't emit findings from files the project doesn't compile). A `.sln` +fans out over its member projects. This is a **dependency-free** resolution +(text/XML glob matching, no MSBuild evaluation) — enough for the common +Include/Remove forms; full MSBuild evaluation (and the project/package/reference +graph) is the `ProjectDependencies`-category work parked for DI/solution scans, not +the v0 leak extractor — see +[`docs/notes/roslyn-tools-and-cli.md`](../../docs/notes/roslyn-tools-and-cli.md). + ## 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 diff --git a/frontend/roslyn/project-input-sample/CustomerSubscription.cs b/frontend/roslyn/project-input-sample/CustomerSubscription.cs new file mode 100644 index 00000000..faf65274 --- /dev/null +++ b/frontend/roslyn/project-input-sample/CustomerSubscription.cs @@ -0,0 +1,23 @@ +using System; + +// SUBSCRIPTION LEAK, discovered via PROJECT-FILE input (not an explicit file list). +// The extractor is pointed at ProjectInputSample.csproj; ProjectCsFiles resolves the +// project to this source file (SDK default-compile-items directory scan), so the same +// `bus.CustomerChanged += handler` with no matching `-=` surfaces as OWN001 — proving +// the .csproj seam feeds the core exactly as the per-file path does. +public sealed class CustomerSubscription +{ + public CustomerSubscription(IEventBus bus) + { + bus.CustomerChanged += OnCustomerChanged; // no matching -= anywhere -> leak + } + + private void OnCustomerChanged(object? sender, EventArgs e) { } +} + +// Local event-bus contract so the subscription binds type-aware (P-014 Tier A) without +// an external reference — keeps this sample self-contained (no OWN050 "unchecked" note). +public interface IEventBus +{ + event EventHandler CustomerChanged; +} diff --git a/frontend/roslyn/project-input-sample/ProjectInputSample.csproj b/frontend/roslyn/project-input-sample/ProjectInputSample.csproj new file mode 100644 index 00000000..9e708d6f --- /dev/null +++ b/frontend/roslyn/project-input-sample/ProjectInputSample.csproj @@ -0,0 +1,16 @@ + + + + + Library + net8.0 + enable + + +