diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67acefac..064ea0f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1065,6 +1065,70 @@ jobs: sarif_file: ${{ steps.own.outputs.sarif-file }} category: own-net-samples + # P-014 Tier B: external-reference resolution. The SAME sample, run two ways, must give two + # verdicts — proving the extractor binds a THIRD-PARTY event only when its DLL is referenced: + # A (no refs) -> ObservableObject is an error type -> OWN050 (honest skip), no leak + # B (--ref-dir DLL) -> PropertyChanged binds to an IEventSymbol -> real OWN001 leak, no OWN050 + # The package DLL is fetched from nuget (a .nupkg is a zip) and pinned to a version known to + # expose the event; Roslyn reads its metadata only (no build, no source, no .NET Framework needed). + tier-b-refs: + name: P-014 Tier B — external reference resolution (--ref-dir) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + - name: Materialize a third-party reference (CommunityToolkit.Mvvm 8.2.2, pinned) + run: | + mkdir -p "$RUNNER_TEMP/refdir" + curl -sSL --retry 3 --max-time 120 -o "$RUNNER_TEMP/ct.nupkg" \ + "https://api.nuget.org/v3-flatcontainer/communitytoolkit.mvvm/8.2.2/communitytoolkit.mvvm.8.2.2.nupkg" + # a .nupkg is a zip; lift just the netstandard2.0 assembly into the ref dir + unzip -o -j "$RUNNER_TEMP/ct.nupkg" "lib/netstandard2.0/CommunityToolkit.Mvvm.dll" -d "$RUNNER_TEMP/refdir" + test -s "$RUNNER_TEMP/refdir/CommunityToolkit.Mvvm.dll" \ + || { echo "FAIL: could not materialize CommunityToolkit.Mvvm.dll"; exit 1; } + # pre-flight: confirm the fixture DLL actually exposes the event the A/B test binds to — + # read its .NET metadata in pure Python (no runtime). A clear "fixture rotted" failure + # beats a confusing "OWN001 not found" if the package ever drops/renames the member. + pip install --quiet dnfile + python - <<'PY' + import os, dnfile + pe = dnfile.dnPE(os.path.join(os.environ["RUNNER_TEMP"], "refdir", "CommunityToolkit.Mvvm.dll")) + ev = getattr(pe.net.mdtables, "Event", None) + events = {str(r.Name) for r in ev.rows} if ev else set() + types = {f"{r.TypeNamespace}.{r.TypeName}" for r in pe.net.mdtables.TypeDef.rows} + assert "PropertyChanged" in events, f"fixture DLL missing PropertyChanged event; has {sorted(events)}" + assert "CommunityToolkit.Mvvm.ComponentModel.ObservableObject" in types, "fixture DLL missing ObservableObject" + print("pre-flight OK: ObservableObject + PropertyChanged present in fixture metadata") + PY + - name: A — without the reference, the external event is OWN050 (honest skip), not a leak + run: | + dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ + frontend/roslyn/samples/TierBSample.cs -o "$RUNNER_TEMP/a.json" + out=$(python -m ownlang ownir "$RUNNER_TEMP/a.json" || true) + echo "$out" + echo "$out" | grep -q "\[OWN050\]" \ + || { echo "FAIL(A): expected OWN050 — ObservableObject unresolved without --ref-dir"; exit 1; } + if echo "$out" | grep -q "\[OWN001\]"; then + echo "FAIL(A): must NOT guess a leak when the declaring type is unresolved"; exit 1 + fi + - name: B — with --ref-dir, the event resolves to a real subscription leak (OWN001) + run: | + dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \ + frontend/roslyn/samples/TierBSample.cs --ref-dir "$RUNNER_TEMP/refdir" -o "$RUNNER_TEMP/b.json" + out=$(python -m ownlang ownir "$RUNNER_TEMP/b.json" || true) + echo "$out" + echo "$out" | grep -q "\[OWN001\]" \ + || { echo "FAIL(B): expected OWN001 — PropertyChanged resolved via --ref-dir"; exit 1; } + if echo "$out" | grep -q "\[OWN050\]"; then + echo "FAIL(B): the external event must RESOLVE, not stay OWN050"; exit 1 + fi + echo "OK: Tier B A/B — external event OWN050 (no ref) -> OWN001 (with --ref-dir)" + # P-012 slice 1: score the checker against the labeled corpus on REAL C# — not # just the .own reduction tests/test_corpus.py checks. Per case: the bug must be # CAUGHT in before.cs (recall) and the fix must be SILENT in after.cs diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f306e134..489dbe58 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -245,6 +245,6 @@ own scan. Label them as estimates wherever they appear. | [P-011](proposals/P-011-editor-tooling.md) | Editor tooling & syntax highlighting | side-track | draft | | [P-012](proposals/P-012-bug-corpus-mining.md) | Real-world bug corpus & mining | enabling | in progress (corpus benchmark + real-world cases, CI-gated) | | [P-013](proposals/P-013-distribution-surface.md) | Distribution surface (CI Action + dotnet tool) | enabling | v0 built | -| [P-014](proposals/P-014-semantic-resolution.md) | Project-local semantic resolution (`+=` event vs number) | P0 | in progress (Tier A shipped & default-on; Tier B deferred) | +| [P-014](proposals/P-014-semantic-resolution.md) | Project-local semantic resolution (`+=` event vs number) | P0 | in progress (Tier A default-on + Tier B light path `--ref-dir`; full MSBuild closure deferred) | | [P-015](proposals/P-015-configuration-surface.md) | Configuration surface (check selection & severity) | P2 | draft (stub) | | [P-016](proposals/P-016-deep-fact-extraction.md) | Deep C# fact extraction (CFG + flow lowering) | P1 | in progress (B0a/B0b/B2/A1 via `--flow-locals`) | diff --git a/docs/proposals/P-014-semantic-resolution.md b/docs/proposals/P-014-semantic-resolution.md index 20d09d28..45823c5f 100644 --- a/docs/proposals/P-014-semantic-resolution.md +++ b/docs/proposals/P-014-semantic-resolution.md @@ -6,9 +6,17 @@ advisory **OWN050** ("declaring type unresolved — leakage analysis skipped"), never a guessed leak. One `CSharpCompilation` over the inputs with framework + `OWN_EXTRA_REF_DIRS` references; the `--event-leaks`/`--no-event-leaks` gate defaults ON. CI-validated on the extractor sample. - **Tier B** (opt-in *full*-reference resolution via `MSBuildWorkspace` / a built `bin/**/*.dll` - reference set, to resolve third-party/DevExpress event types beyond the framework set) is - deferred (open question Q3), as are monorepo scoping (Q1) and a general check-selection surface (Q2). + **Tier B (light path) shipped:** `--ref-dir ` (repeatable) widens the compilation's + reference set RECURSIVELY from a built `bin/` (or a restored package's `lib/`), so events on + third-party types (DevExpress, etc.) bind to real symbols instead of OWN050 — first simple-name + wins, so framework/TPA references are never double-added, and an unloadable DLL is skipped, not + fatal. CI-validated by an **A/B test** (the `tier-b-refs` job): the same sample subscribing to a + `CommunityToolkit.Mvvm.ComponentModel.ObservableObject.PropertyChanged` yields OWN050 *without* the reference and + a real OWN001 *with* `--ref-dir` — proving resolution flips the verdict. Roslyn reads metadata + only, so a .NET Framework `bin/` resolves exactly as a modern-.NET one (only the DLLs differ). + **Still deferred:** the heavier auto-discovery of a project's *full transitive closure* from a + `.csproj`/`.sln` without a prior build (`MSBuildWorkspace`, open question Q3), monorepo scoping + (Q1), and a general check-selection surface (Q2). - **Depends on:** - [P-001](P-001-csharp-extractor.md) — the seam this deepens. P-001 defines the Roslyn-extractor → OwnIR → Python-core pipeline (P-001:71-77) *and* owns the diff --git a/docs/proposals/README.md b/docs/proposals/README.md index 9ebd2806..7fbaafdd 100644 --- a/docs/proposals/README.md +++ b/docs/proposals/README.md @@ -35,7 +35,7 @@ proposal is marked `done` with a pointer. | [P-011](P-011-editor-tooling.md) | Editor tooling & syntax highlighting | draft | | [P-012](P-012-bug-corpus-mining.md) | Real-world bug corpus & mining | in progress (corpus benchmark + real-world cases, CI-gated) | | [P-013](P-013-distribution-surface.md) | Distribution surface (how people run Own.NET) | v0 built (CI/Action + dotnet tool) | -| [P-014](P-014-semantic-resolution.md) | Project-local semantic resolution (kills `+=` false positives) | in progress (Tier A shipped & default-on; Tier B deferred) | +| [P-014](P-014-semantic-resolution.md) | Project-local semantic resolution (kills `+=` false positives) | in progress (Tier A default-on + Tier B light path `--ref-dir`; full MSBuild closure deferred) | | [P-015](P-015-configuration-surface.md) | Configuration surface (check selection & per-category severity) | draft (stub) | | [P-016](P-016-deep-fact-extraction.md) | Deep C# fact extraction (CFG + flow lowering; loops) | in progress (B0a/B0b/B2/A1 via `--flow-locals`) | diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 45482b35..4c48dc76 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -31,6 +31,14 @@ var rawInputs = new List(); string? outPath = null; +// --ref-dir (repeatable, P-014 Tier B): widen the compilation's reference set with the +// DLLs under , searched RECURSIVELY — point it at a project's built `bin/` output (or a +// restored package's `lib/`) and the SemanticModel can then bind events on third-party types +// (DevExpress, etc.) instead of surfacing them as OWN050. The first-class, scriptable twin of the +// OWN_EXTRA_REF_DIRS env var (which stays non-recursive, for framework ref packs). Roslyn reads +// metadata only, so it resolves a .NET Framework `bin/` exactly as a modern-.NET one — only the +// DLLs differ. First simple-name wins, so a TPA/framework reference is never double-added. +var refDirs = new List(); // Event-subscription detection is on by default now that it is type-aware // (P-014 Tier A graduates it from the interim off). `--no-event-leaks` opts out // (e.g. to run only the disposable/pool detectors); it is the first instance of @@ -59,6 +67,7 @@ for (int i = 0; i < args.Length; i++) { if (args[i] == "-o" && i + 1 < args.Length) outPath = 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; else if (args[i] == "--body-throw-edges") BodyThrowEdges = true; @@ -68,7 +77,7 @@ if (rawInputs.Count == 0) { - Console.Error.WriteLine("usage: ownsharp-extract [...] [-o facts.json]"); + Console.Error.WriteLine("usage: ownsharp-extract [...] [-o facts.json] [--ref-dir ]"); return 2; } @@ -2392,6 +2401,30 @@ static bool IsPublicCtor(SyntaxTokenList modifiers) { references.Add(MetadataReference.CreateFromFile(dll)); added++; } Console.Error.WriteLine($"extractor: +{added} extra references from {dir}"); } +// P-014 Tier B: --ref-dir widens the reference set RECURSIVELY (a project's built `bin/`, +// a restored package's `lib/`), so events on third-party types resolve to real symbols. First +// simple-name wins (a TPA/framework or OWN_EXTRA_REF_DIRS reference already loaded is skipped), +// so a `bin/` carrying multiple target-framework copies of the same assembly references one. A +// reference that fails to load (a native DLL, a corrupt file) is skipped, not fatal — we only read +// metadata, and a missing reference degrades to OWN050, never a crash. +foreach (var dir in refDirs) +{ + if (!Directory.Exists(dir)) { Console.Error.WriteLine($"extractor: --ref-dir not found: {dir}"); continue; } + var added = 0; + // Ordinal sort makes "first simple-name wins" deterministic across platforms/filesystems + // (EnumerateFiles order is unspecified), so a `bin/` with multiple TFM copies picks the same one. + foreach (var dll in Directory.EnumerateFiles(dir, "*.dll", SearchOption.AllDirectories) + .OrderBy(p => p, StringComparer.Ordinal)) + { + var name = Path.GetFileName(dll); + if (!refNames.Contains(name)) + // Record the name only on a successful load, so a failed DLL here doesn't burn the + // name and silently skip a loadable same-named assembly elsewhere in the tree. + try { references.Add(MetadataReference.CreateFromFile(dll)); refNames.Add(name); added++; } + catch (Exception ex) { Console.Error.WriteLine($"extractor: skipped {name}: {ex.GetType().Name}"); } + } + Console.Error.WriteLine($"extractor: +{added} references from --ref-dir {dir} (recursive)"); +} var compilation = CSharpCompilation.Create( "own", parsed.Select(p => p.tree), references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); diff --git a/frontend/roslyn/samples/TierBSample.cs b/frontend/roslyn/samples/TierBSample.cs new file mode 100644 index 00000000..31961de2 --- /dev/null +++ b/frontend/roslyn/samples/TierBSample.cs @@ -0,0 +1,34 @@ +// P-014 Tier B — proof that external-reference resolution flips an event from "unchecked" +// to a real, resolved subscription. `TierBSubscriber` holds a long-lived reference to a +// CommunityToolkit.Mvvm `ObservableObject` (a THIRD-PARTY type, not in the framework set) +// and subscribes to its `PropertyChanged` without ever `-=`'ing it. +// +// WITHOUT --ref-dir : CommunityToolkit.Mvvm.dll is not referenced, so `ObservableObject` +// is an error type and `_vm.PropertyChanged += ...` cannot bind to an +// event symbol -> the extractor emits the advisory OWN050 ("leakage +// analysis skipped"), NEVER a guessed leak. +// WITH --ref-dir : the package DLL is referenced, the SemanticModel binds PropertyChanged +// to an IEventSymbol, and the un-detached subscription is a real OWN001 +// leak (warning-tier — the source is injected, of unknown lifetime). +// +// Same source, two reference sets, two verdicts: the A/B delta is the Tier B proof. Roslyn reads +// metadata only, so this resolves a .NET Framework `bin/` exactly as a modern-.NET one — only the +// referenced DLLs differ. Exercised by the `tier-b-refs` CI job (ci.yml). +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace TierBSample +{ + public sealed class TierBSubscriber + { + private readonly ObservableObject _vm; + + public TierBSubscriber(ObservableObject vm) + { + _vm = vm; + _vm.PropertyChanged += OnChanged; // subscribed, never -='d + } + + private void OnChanged(object? sender, PropertyChangedEventArgs e) { } + } +}