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) { }
+ }
+}