The C# half of the P-001 pipeline: scan real C# and emit OwnIR facts that the existing Python core checks.
*.cs --[OwnSharp.Extractor (Roslyn)]--> facts.json --[python -m ownlang ownir]--> OWN001 @ C# location
Type-aware (P-014 Tier A): all inputs are parsed into one CSharpCompilation with
the runtime's framework references, and a target += handler is an event
subscription only when the SemanticModel binds the left side to an event — so
sum += value (arithmetic) is not a leak. Each is marked released iff a matching
target -= handler exists in the same class. When the left side's declaring type
is an unresolved external reference, it surfaces as an OWN050 "leakage analysis
skipped" note, never guessed as a leak. Still fact-only and intraprocedural; the
verdict (OWN001) comes from the core, not from here — there is one checker, not two.
dotnet run --project OwnSharp.Extractor -- samples/CustomerViewModel.cs samples/OrdersViewModel.cs -o facts.json
python -m ownlang ownir facts.json
# -> CustomerViewModel.cs:9: error: [OWN001] event 'bus.CustomerChanged' ... (leak)
# (OrdersViewModel unsubscribes in Dispose -> nothing reported)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:
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
dotnet run --project OwnSharp.Extractor -- extract --project App.csproj --out facts.json # explicit verbextract is an optional leading verb (the tool's one job; the bare form is the
default), and --out is the long twin of -o. The sibling verbs live where the
architecture puts them — check is scripts/own-check.sh (which chains this + the
core and accepts a .csproj/.sln), and explain is in the core:
python -m ownlang explain OWN001 (or --json findings.sarif — the checker's
findings/SARIF output — to explain every code a run produced; note facts.json
holds extractor facts, not diagnostic codes). One checker: the C# tool only emits
facts.
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
<Compile Include="..\Shared\Foo.cs" /> outside the project tree — while honouring
the project's explicit compile set: <EnableDefaultCompileItems>false switches to
include-driven, and <Compile Remove="..."> 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.
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.
With --flow-locals the extractor also lowers a state-protocol surface — the
first slice of P-010 pillar 9 —
into the OwnIR ops move and borrow_mut (OwnIR.md §5.3),
and, since OwnIR v2, proven_call: a call inside a region that touches neither the
entity nor the token and is dispatched direct is handed to the core, which admits it
only when the shared heap-effect summaries prove it harmless (OwnIR.md §5.4,
H1).
(These are state protocols — which state an entity is in. The obligation
protocols of P-025 / OwnIR §8 are a different mechanism with a similar name.)
It is recognised by two attributes matched by name, so a domain carries no
dependency on Own.NET:
[ProtocolToken] // a state: a ref struct over the entity
public readonly ref struct ApprovedOrder
{
private readonly Order _order;
internal ApprovedOrder(Order order) => _order = order;
public void Ship(DateTime at) => _order.MarkShipped(at); // a transition: spends the token
}
public static class OrderProtocol
{
[ProtocolRegion] // a region entry: (entity, callback)
public static void WithApproved(Order order, ApprovedRegion body) { /* check, then */ body(new ApprovedOrder(order)); }
}
// a handler: no attribute, no base class, no repository — the entity EF tracks
OrderProtocol.WithApproved(order, approved =>
{
approved.Ship(now);
});Inside the callback the entity is exclusively borrowed: only its token may
change it, a token is spent once (use after a transition is OWN002, a copy is a
move — OWN005), and any other mention of the entity is OWN013. The verdicts are
the core's; the extractor only lowers. A complete backend — ASP.NET Core minimal
API, EF Core, SQLite — is in
protocol-samples/efcore, and
python scripts/protocol_gate.py ties every committed fact back to the C# it came
from.
The product-shaped version of the same backend, with the protocol generated
from one annotated declaration (frontend/roslyn/Own.TypedBuilder), a typed
builder, and every transition over HTTP, is
samples/OrderBackend (TB-MVP-01,
python scripts/typed_builder_gate.py).
What is claimed. The profile protects the local C# capabilities and aliases of
an entity that already exists. It does not protect the persisted row from
other ways of changing it: ExecuteUpdate, a change-tracker metadata write, raw
SQL, another process. Those belong to concurrency tokens, constraints and
transactions (two of them are pinned as stated limits under
protocol-samples/efcore/known-gaps).
Reserved names. Matching by name makes ProtocolToken and ProtocolRegion
names the profile owns — but only in its own shapes: a ref struct marked
[ProtocolToken] is a state token, a method marked [ProtocolRegion] that
takes a delegate is a region entry. An attribute with one of these names on
anything else (a class, a plain struct, a record, a method with no callback)
belongs to somebody else's code and is ignored, so a codebase that declares no
state protocol is not refused over a name. The two shapes themselves stay
reserved: an unrelated attribute of the same name on a ref struct, or on a method
that takes a delegate, is read as a protocol declared wrongly and refused — the
profile cannot tell the two apart, and a mis-declared protocol must not go
unanalysed.
The trust boundary. The types that DECLARE a protocol — the tokens, and the type holding a region entry — are its trusted definition surface: they construct tokens, enter regions and implement transitions, and their bodies are not lowered. Everything else is consumer code, and consumer code is what the profile analyses. A type is one or the other, never both. A handler written inside a declaring type would not be a violation the core missed; it would be a program the core never saw, and it would read as clean. So a region opened inside a declaring type is refused, and the profile does not try to guess which method of such a type is plumbing and which is a business handler.
Refusals. A recognised protocol construct that cannot be lowered safely is
not skipped: the extractor exits 2 and writes no facts, for the whole scan.
- Inside a region the rule is default-deny. Transitions, token reads,
if, locals and built-in operators are read; a call, a constructor, a property getter, an indexer, a loop,returnare refused — nothing states what such code does to the entity. Read what you need (DateTime.UtcNow, a logger call) before the region. Around a region anything goes:try/catch,await using, loops. - Admission. The state a transition writes must have no public mutator on the entity (a public setter, or a public method reaching the same write): that would be a transition nobody declared.
- Boundary. Outside the protocol's own types, creating a token (
new,default), calling a non-public method of the entity or the protocol, and writing their non-publicly-writable state are refused — one assembly or two.nameof,typeofand reads are mentions, not operations. - Source. The protocol and its entity must be in the scan as source; one that arrives only as a compiled reference cannot be admitted.
- Trust boundary. A region opened inside a type that declares the protocol (see above): write the code that uses a protocol outside the types that declare it.
- Tokens stay in regions. A lambda or local function that takes a token must be
the in-place body of a region entry. A token handed to a callback any other
way — by a method of the protocol's own type that is not marked
[ProtocolRegion], for one — reaches consumer code nobody lowered. - Binding. The scan does not read
obj/, so usings a project only gets implicitly are not there. A region whose entity does not bind is refused; write the usings out in the files that open regions (or qualify the names).
The two stages are chained by one orchestrator script, so you don't run them by
hand. It scans a directory (recursively, skipping bin/obj/generated files):
# from an Own.NET checkout, scan another repo's C#:
scripts/own-check.sh --format human -- /path/to/some/csharp/repo
scripts/own-check.sh --format msbuild -- . # VS Error List format
scripts/own-check.sh --fail-on-finding -- src/ # non-zero exit on a leak--format is the core's surface selector (the renderer lives in
ownlang/ownir.py, not here — one checker):
human— the CLI line (default);github—::error file=…,line=…::…annotations on the PR diff;msbuild—file(line): error OWN001: …, whichdotnet buildand the Visual Studio Error List parse, so findings surface in-IDE with no analyzer.
GitHub Action. A composite action (action.yml) wraps the same script. A
consumer repo adds (see examples/ci/own-check.yml):
- uses: actions/checkout@v4
- uses: PhysShell/own.net@main
with: { path: ., format: github, fail-on-finding: "true" }dotnet tool. The extractor alone is packable
(dotnet pack → dotnet tool install --global OwnSharp.Extractor →
ownsharp-extract). It emits facts only; the verdict still comes from the
Python core, so the script/Action are the complete product. For the single
command that wraps both stages into one install, see
OwnSharp.Cli (alpha gate A, issue #202) —
ownsharp check <path|.sln>.
Why CI/CLI and not a native Roslyn analyzer: a true DiagnosticAnalyzer runs
in-process and would force a second checker in C# (or shelling out to Python
per keystroke) — a conflict with "one checker", not just effort. See
P-013.
Step-by-step usage (terminal, Visual Studio Error List, CI) lives in
docs/howto-visual-studio.md.
This sandbox has no local dotnet, so the extractor is built and run only in CI
(the wpf-extractor job); the Python bridge + core are tested locally
(tests/test_ownir.py) against hand-written facts. Event subscriptions are
resolved type-aware (P-014 Tier A); resolving external events (WPF/DevExpress)
needs their references (P-014 Tier B, opt-in) — until then they surface as OWN050
"unchecked" notes. The IDisposable-field / local / pool detectors remain syntactic
for now (P-014 rollout: the event fact goes type-aware first).