Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,15 @@ jobs:
echo "--- diagnostics ---"; echo "$out"; echo "-------------------"
echo "$out" | grep -qE "CustomerViewModel\.cs\([0-9]+\): error OWN001:" \
|| { echo "FAIL: expected an MSBuild-format error line"; exit 1; }
- name: --severity warning renders advisory diagnostics
run: |
out=$(scripts/own-check.sh --format msbuild --severity warning -- frontend/roslyn/samples)
echo "$out"
echo "$out" | grep -qE "CustomerViewModel\.cs\([0-9]+\): warning OWN001:" \
|| { echo "FAIL: expected an MSBuild-format warning line"; exit 1; }
if echo "$out" | grep -qE ": error OWN001:"; then
echo "FAIL: --severity warning should not emit error-level lines"; exit 1
fi
- name: --fail-on-finding propagates the core's exit code
run: |
if scripts/own-check.sh --fail-on-finding -- frontend/roslyn/samples >/dev/null 2>&1; then
Expand Down
7 changes: 6 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ inputs:
description: "Finding surface: github (PR annotations), msbuild, or human."
required: false
default: "github"
severity:
description: "How findings are shown: error (default) or warning (advisory)."
required: false
default: "error"
fail-on-finding:
description: "Fail the step when any leak is found."
required: false
Expand Down Expand Up @@ -52,9 +56,10 @@ runs:
# it. CodeRabbit #10.
OWN_PATH: ${{ inputs.path }}
OWN_FORMAT: ${{ inputs.format }}
OWN_SEVERITY: ${{ inputs.severity }}
OWN_FAIL_ON_FINDING: ${{ inputs.fail-on-finding }}
run: |
args=(--root "${{ github.action_path }}" --format "$OWN_FORMAT")
args=(--root "${{ github.action_path }}" --format "$OWN_FORMAT" --severity "$OWN_SEVERITY")
if [ "$OWN_FAIL_ON_FINDING" = "true" ]; then
args+=(--fail-on-finding)
fi
Expand Down
5 changes: 3 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ architectural strictness, and the borrow-checker showcase):
1. `WPF001` — event/subscription `+=` without `-=` (the WPF spike; P-001 v0 ✅)
2. `WPF002` — `DispatcherTimer`/`Timer` `Tick`/`Elapsed` without stop/detach ✅
3. `OWN001` — `IDisposable` field the class `new`s but never disposes ✅
4. `DI001` — singleton captures a scoped dependency
4. `DI001` — singleton captures a scoped dependency ✅ (core check built;
C# registration-graph extractor pending)
5. `POOL001` — `ArrayPool` buffer `Rent`ed but never `Return`ed ✅
(`POOL002` `Span`/view used after `Return` next)

Expand Down Expand Up @@ -149,7 +150,7 @@ own scan. Label them as estimates wherever they appear.
| [P-003](proposals/P-003-lifetime-visualization.md) | Lifetime visualization (RustOwl-style) | horizon | draft |
| [P-004](proposals/P-004-wpf-lifetime-profile.md) | WPF / UI lifetime leak profile | P0 | draft |
| [P-005](proposals/P-005-idisposable-ownership.md) | `IDisposable` ownership profile | P0 | draft |
| [P-006](proposals/P-006-di-lifetimes.md) | DI lifetime / captive dependency | P0 | draft |
| [P-006](proposals/P-006-di-lifetimes.md) | DI lifetime / captive dependency | P0 | in progress (DI001 core check built) |
| [P-007](proposals/P-007-arraypool-span.md) | ArrayPool / Span borrow-view | P1 | draft |
| [P-008](proposals/P-008-effects-and-resources.md) | Effects & resources (`Own.Effects`) | P1/P2 | draft |
| [P-009](proposals/P-009-nogc-regions.md) | No-GC / allocation-free regions | horizon | draft |
Expand Down
160 changes: 160 additions & 0 deletions docs/howto-visual-studio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# How-to: run Own.NET on your C# (CLI · CI · Visual Studio)

Own.NET finds lifetime/resource leaks C# can't express (event/timer leaks,
undisposed `IDisposable`, ignored `Subscribe()` tokens, `ArrayPool` rent-without-
return). This guide shows the three ways to actually *run* it on your code.

> **There is no VSIX and no Roslyn analyzer.** On purpose — the checker is one
> Python core, and an in-process analyzer would force a second checker in C# (or
> shell out to Python on every keystroke). Instead the same finding is rendered
> in a format the host already understands: GitHub annotations for CI, and the
> **MSBuild diagnostic format** (`file(line): error CODE: …`) that the Visual
> Studio **Error List** parses for free. See
> [P-013](proposals/P-013-distribution-surface.md) for the why.

## 0. Prerequisites

The pipeline is two stages — a Roslyn extractor (C#) and the Python core — so you
need both runtimes plus an Own.NET checkout:

- **.NET SDK** 8.0+ (`dotnet`) — builds/runs the extractor.
- **Python** 3.11+ — runs the core.
- An **Own.NET checkout** somewhere on disk; below it is `$OWN` (e.g.
`git clone https://github.com/PhysShell/Own.NET ~/own.net`).

Nothing is installed into your project — the tool reads your `.cs`, it does not
add a dependency.

## 1. From a terminal (the foundation everything else wraps)

```bash
# human-readable (default)
"$OWN/scripts/own-check.sh" -- path/to/your/Project

# Visual Studio / MSBuild Error List format
"$OWN/scripts/own-check.sh" --format msbuild -- path/to/your/Project

# CI annotations; non-zero exit on any finding
"$OWN/scripts/own-check.sh" --format github --fail-on-finding -- .

# advisory: render findings as warnings (won't fail a build)
"$OWN/scripts/own-check.sh" --format msbuild --severity warning -- path/to/your/Project
```

`own-check.sh` walks the path for `*.cs` (skipping `bin`/`obj`/`.git`/
`node_modules`/`packages` and generated files), runs the extractor → the core,
and prints findings. `--format msbuild` prints exactly:

```text
src/Vm/CustomerViewModel.cs(12): error OWN001: event 'bus.CustomerChanged' is subscribed (handler 'OnCustomerChanged') but never unsubscribed — the source keeps 'CustomerViewModel' alive (leak) [resource: subscription token]
```

That line shape is the canonical MSBuild diagnostic — which is the whole trick
for Visual Studio.

Exit codes: `0` clean · `1` findings (only with `--fail-on-finding`) · `≥2` a
hard error (bad facts). Without `--fail-on-finding` the script always exits `0`
so it never breaks a wrapping build by accident.

## 2. In Visual Studio

Two approaches, cheapest first.

### Option A — External Tool (on-demand, no build coupling) — recommended

Run the check from a menu item and get clickable results. **Tools → External
Tools… → Add:**

| Field | Value |
| --- | --- |
| Title | `Own.NET leak check` |
| Command | `bash` (Linux/macOS), or `C:\Program Files\Git\bin\bash.exe` (Git Bash on Windows) |
| Arguments | `"<OWN>/scripts/own-check.sh" --format msbuild -- "$(ProjectDir)"` |
| Initial directory | `$(SolutionDir)` |
| ✔ **Use Output window** | checked |

Run it from **Tools → Own.NET leak check**. Output appears in the Output window,
and because the lines are in canonical MSBuild format, Visual Studio makes each
one **double-click-to-navigate** to the exact file and line.

> On Windows the script needs a bash (WSL or Git Bash). If you'd rather not, use
> the raw two-command form from §4 in a `.cmd`/PowerShell External Tool instead.

### Option B — MSBuild target (findings in the Error List on every build)

Drop a `Directory.Build.targets` next to your solution (or add the `<Target>` to
a `.csproj`):

```xml
<Project>
<PropertyGroup>
<!-- Path to your Own.NET checkout. -->
<OwnNetRoot>/abs/path/to/own.net</OwnNetRoot>
</PropertyGroup>

<Target Name="OwnNetLeakCheck" BeforeTargets="Build">
<Exec Command="bash &quot;$(OwnNetRoot)/scripts/own-check.sh&quot; --format msbuild --severity warning -- &quot;$(MSBuildProjectDirectory)&quot;"
ContinueOnError="true" />
</Target>
</Project>
```

The MSBuild `Exec` task scans the command's output for canonical diagnostic
lines and raises them as build diagnostics, so they land in the **Error List**
and the build log without any analyzer.

**Severity:** `--severity` chooses how the host shows a finding. The target
above uses `--severity warning` so findings are **advisory** — they appear in
the Error List but don't fail the build, which is what you want on every
inner-loop build. Drop `--severity warning` (the default is `error`) if you'd
rather a leak break the build, e.g. on a release/CI configuration.

> Note: this runs the extractor build (`dotnet run`) as part of your build, which
> adds a few seconds. For large solutions prefer Option A or the CI job (§3) over
> a `BeforeTargets="Build"` hook on every inner-loop build.

## 3. In CI (GitHub Actions)

The reusable composite action annotates the PR diff. Add to a workflow (full
example in [`examples/ci/own-check.yml`](../examples/ci/own-check.yml)):

```yaml
- uses: actions/checkout@v4
- uses: PhysShell/own.net@main # pin a tag for stability
with:
path: .
format: github
fail-on-finding: "true"
```

Findings appear as inline `error` annotations on the changed lines, and the
check goes red. This is the path that needs no local setup at all.

## 4. Windows without bash (PowerShell)

If you have no bash, use the bundled PowerShell twin — same flags, same output,
no shell dependency:

```powershell
& "$OWN\scripts\own-check.ps1" -Format msbuild -- src\MyApp
& "$OWN\scripts\own-check.ps1" -Format github -Severity warning -FailOnFinding -- .
```

Point a Visual Studio External Tool (§2A) at `powershell.exe` with arguments
`-File "<OWN>\scripts\own-check.ps1" -Format msbuild -- "$(ProjectDir)"`, or an
MSBuild `Exec` (§2B) at `powershell -File "$(OwnNetRoot)\scripts\own-check.ps1" …`,
instead of the bash command.

## Caveats

- **Heuristic findings can be false positives** (e.g. ownership handed to a
callee). Treat output as a reviewer, not a gate, until you've calibrated it on
your codebase — and prefer `--fail-on-finding` only once it's quiet.
- **One method at a time, syntax-only.** The extractor does not do
interprocedural/`async`/whole-program analysis yet (by design — see the
ROADMAP). It honestly skips what it can't model rather than guessing.
- **`bin`/`obj`/generated files are skipped**; paths are reported relative to the
scan root so they resolve in the editor and on the PR.

For the design rationale and the full surface ladder, see
[P-013](proposals/P-013-distribution-surface.md).
7 changes: 6 additions & 1 deletion docs/proposals/P-006-di-lifetimes.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# P-006 — DI lifetime / captive dependency profile

- **Status:** draft (P0 — clean lifetime model, little R&D, sells to ASP.NET)
- **Status:** in progress (P0 — clean lifetime model, little R&D, sells to
ASP.NET). DI001 captive-dependency check built in the core (`ownlang/di.py`)
over an OwnIR `services` registration graph, surfaced through the bridge with
hand-written facts + tests. Next: the C# extractor that builds the registration
graph from `services.Add{Singleton,Scoped,Transient}` + constructor injection
(CI-only, like the rest of the extractor).
- **Depends on:** `spec/Lifetimes.md` (the region-ordering model behind OWN014),
[P-001](P-001-csharp-extractor.md) (the C# seam). See
[`docs/ROADMAP.md`](../ROADMAP.md) (Milestone 3).
Expand Down
15 changes: 9 additions & 6 deletions docs/proposals/P-013-distribution-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ and defer the native analyzer until (if ever) the core itself moves to .NET.
files (`*.g.cs`, `*.Designer.cs`, `*.AssemblyInfo.cs`). Finding paths are
reported relative to the working directory (forward slashes) so annotations
point at the right file even when names collide.
- **`scripts/own-check.sh`.** One command that chains both stages (extractor →
core) with `--format` and `--fail-on-finding`. The body of the Action and a
standalone local command.
- **`scripts/own-check.sh`** (+ a PowerShell twin **`scripts/own-check.ps1`** for
Windows/VS users without bash). One command that chains both stages (extractor
→ core) with `--format`, `--severity`, and `--fail-on-finding`. The body of the
Action and a standalone local command.
- **`action.yml`** — a composite GitHub Action (`uses: PhysShell/own.net@…`):
sets up Python + .NET, runs `own-check.sh` over the consumer's checkout,
annotates the PR. Example consumer workflow in `examples/ci/own-check.yml`.
Expand Down Expand Up @@ -84,9 +85,11 @@ not overlap.

## Open questions

1. MSBuild severity: emit findings as `error` (fails a parsing build) or
`warning` (advisory)? v0 uses `error` to match the core; the Action's
`fail-on-finding` already gates CI independently.
1. ~~MSBuild severity: emit findings as `error` or `warning`?~~ **Resolved:**
a `--severity {error,warning}` flag (default `error`) is threaded through the
core renderer, `own-check.sh`/`.ps1`, and the Action's `severity` input, so a
build can show findings advisory (warning) without failing. The Action's
`fail-on-finding` still gates CI independently.
2. Should `own-check` grow a `--baseline`/diff mode (only new findings on a PR)
so adopting it on a legacy repo isn't an immediate wall of red?
3. Action distribution: a moving `@main`, or tagged releases (`@v0.1.0`) +
Expand Down
3 changes: 3 additions & 0 deletions frontend/roslyn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ 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](../../docs/proposals/P-013-distribution-surface.md).

**Step-by-step usage** (terminal, Visual Studio Error List, CI) lives in
[`docs/howto-visual-studio.md`](../../docs/howto-visual-studio.md).

## Scope / honesty

This sandbox has no local `dotnet`, so the extractor is built and run only in CI
Expand Down
59 changes: 39 additions & 20 deletions ownlang/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
python -m ownlang cfg file.own # dump the control-flow graph
python -m ownlang report file.own # buffer storage report + .ownreport.json
python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)
python -m ownlang ownir facts.json --format github|msbuild|human
python -m ownlang ownir facts.json --format github|msbuild|human [--severity error|warning]

`--format` (ownir only) selects the finding surface: `human` (default CLI line),
`github` (CI annotations on the PR diff), or `msbuild` (VS Error List).
`--severity` (ownir only) picks how the host shows a finding — `error` (default,
fails a build / red check) or `warning` (advisory). It is a presentation choice;
the finding is still the core's verdict.

Exit code is non-zero if any error-level diagnostic was produced.
"""
Expand Down Expand Up @@ -185,10 +188,11 @@ def _read(path: str) -> str:
return f.read()


def cmd_ownir(path: str, fmt: str = "human") -> int:
def cmd_ownir(path: str, fmt: str = "human", severity: str = "error") -> int:
"""Check OwnIR facts (extracted from real C# by the Roslyn frontend) through
the same core, surfacing findings at their C# locations (P-001). `fmt`
selects the surface: human (CLI), github (CI annotations), msbuild (VS)."""
selects the surface: human (CLI), github (CI annotations), msbuild (VS);
`severity` picks how the host shows them (error/warning)."""
from .ownir import OwnIRError, check_facts, load, render_finding
try:
findings = check_facts(load(path))
Expand All @@ -202,7 +206,7 @@ def cmd_ownir(path: str, fmt: str = "human") -> int:
machine = fmt in {"github", "msbuild"}
summary_to = sys.stderr if machine else sys.stdout
for f in findings:
print(render_finding(f, fmt))
print(render_finding(f, fmt, severity))
if not findings:
print(f"{path}: ok — no subscription leaks found", file=summary_to)
n = len(findings)
Expand All @@ -211,31 +215,38 @@ def cmd_ownir(path: str, fmt: str = "human") -> int:


_FORMATS = {"human", "github", "msbuild"}
_SEVERITIES = {"error", "warning"}


def main(argv: list[str]) -> int:
if not argv or argv[0] not in {"check", "emit", "cfg", "report", "ownir"}:
print(__doc__)
return 2
cmd = argv[0]
# Pull the optional `--format X` / `--format=X` flag (ownir only) out of the
# arguments; everything else is positional. Keeps the other commands' single
# positional-path contract intact.
fmt = "human"
# Pull the optional value-flags (`--format`/`--severity`, ownir only) out of
# the arguments in either `--flag V` or `--flag=V` form; everything else is
# positional. Keeps the other commands' single positional-path contract.
opts = {"--format": "human", "--severity": "error"}
seen_value_flags = False
positional: list[str] = []
rest = argv[1:]
i = 0
while i < len(rest):
a = rest[i]
if a == "--format":
if i + 1 >= len(rest):
print("--format requires a value: human|github|msbuild",
file=sys.stderr)
return 2
fmt, i = rest[i + 1], i + 2
continue
if a.startswith("--format="):
fmt, i = a.split("=", 1)[1], i + 1
matched = False
for flag in opts:
if a == flag:
if i + 1 >= len(rest):
print(f"{flag} requires a value", file=sys.stderr)
return 2
opts[flag], i = rest[i + 1], i + 2
seen_value_flags = matched = True
break
if a.startswith(flag + "="):
opts[flag], i = a.split("=", 1)[1], i + 1
seen_value_flags = matched = True
break
if matched:
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
positional.append(a)
i += 1
Expand All @@ -244,16 +255,24 @@ def main(argv: list[str]) -> int:
if len(positional) != 1:
print(__doc__)
return 2
fmt, severity = opts["--format"], opts["--severity"]
if fmt not in _FORMATS:
print(f"unknown --format {fmt!r} (choose: {', '.join(sorted(_FORMATS))})",
file=sys.stderr)
return 2
if cmd != "ownir" and fmt != "human":
print("--format only applies to `ownir`", file=sys.stderr)
if severity not in _SEVERITIES:
print(f"unknown --severity {severity!r} (choose: "
f"{', '.join(sorted(_SEVERITIES))})", file=sys.stderr)
return 2
# `--format`/`--severity` are ownir-only — reject them on other commands by
# *presence*, not just non-default value (so `check x --format human` is a
# clear error, not a silent no-op).
if cmd != "ownir" and seen_value_flags:
print("--format/--severity only apply to `ownir`", file=sys.stderr)
return 2
path = positional[0]
if cmd == "ownir":
return cmd_ownir(path, fmt)
return cmd_ownir(path, fmt, severity)
return {"check": cmd_check, "emit": cmd_emit, "cfg": cmd_cfg,
"report": cmd_report}[cmd](path)

Expand Down
Loading
Loading