diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 53db3d8d..7ac86b2b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -175,3 +175,50 @@ jobs:
fi
echo "OK: real C# -> facts -> OWN001 (subscription + timer + field + Subscribe + pool + local) at the C# location"
+ # The distribution surface (Уровень 1): the own-check.sh orchestrator walks a
+ # directory of real C# and prints findings in the host-parseable formats the
+ # GitHub Action (PR annotations) and a VS Error List (MSBuild) consume — and
+ # the composite action itself runs end-to-end. One checker: the script just
+ # chains the extractor and the Python core.
+ own-check-surface:
+ name: own-check repo scan (github + msbuild) + composite action
+ 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: GitHub-annotation format over the sample tree (directory walk)
+ run: |
+ # stdout (captured) carries only the annotations; the extractor's build
+ # chatter and any error flow to stderr -> the job log (never muted).
+ out=$(scripts/own-check.sh --format github -- frontend/roslyn/samples)
+ echo "--- annotations ---"; echo "$out"; echo "-------------------"
+ echo "$out" | grep -q "^::error " \
+ || { echo "FAIL: expected a ::error annotation"; exit 1; }
+ echo "$out" | grep -q "frontend/roslyn/samples/CustomerViewModel.cs" \
+ || { echo "FAIL: expected the relative path to the Customer leak"; exit 1; }
+ echo "$out" | grep -q "title=OWN001" \
+ || { echo "FAIL: expected the OWN001 title in the annotation"; exit 1; }
+ - name: MSBuild diagnostic format over the sample tree
+ run: |
+ out=$(scripts/own-check.sh --format msbuild -- frontend/roslyn/samples)
+ 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: --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
+ echo "FAIL: a tree with leaks should exit non-zero under --fail-on-finding"; exit 1
+ fi
+ echo "OK: --fail-on-finding surfaced the leaks as a non-zero exit"
+ - name: The composite action runs end-to-end (non-failing)
+ uses: ./
+ with:
+ path: frontend/roslyn/samples
+ format: github
+ fail-on-finding: "false"
+
diff --git a/action.yml b/action.yml
new file mode 100644
index 00000000..c95ecff2
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,61 @@
+name: "Own.NET resource-leak check"
+description: >-
+ Scan C# for lifetime/resource leaks the compiler cannot express — event/timer
+ subscription leaks, undisposed IDisposable fields/locals, ignored Subscribe()
+ tokens, ArrayPool buffers rented-but-never-returned — and annotate the PR.
+author: "Own.NET"
+branding:
+ icon: "shield"
+ color: "purple"
+
+inputs:
+ path:
+ description: "File(s) or directory to scan (directories are walked for *.cs)."
+ required: false
+ default: "."
+ format:
+ description: "Finding surface: github (PR annotations), msbuild, or human."
+ required: false
+ default: "github"
+ fail-on-finding:
+ description: "Fail the step when any leak is found."
+ required: false
+ default: "true"
+ python-version:
+ description: "Python version for the Own.NET core."
+ required: false
+ default: "3.13"
+ dotnet-version:
+ description: "The .NET SDK version for the Roslyn extractor."
+ required: false
+ default: "8.0.x"
+
+runs:
+ using: "composite"
+ steps:
+ - name: Set up Python (Own.NET core)
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ inputs.python-version }}
+
+ - name: Set up .NET (Roslyn extractor)
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ inputs.dotnet-version }}
+
+ - name: Own.NET leak check
+ shell: bash
+ env:
+ # Pass user-controlled inputs through the environment (data), not by
+ # template interpolation into the script body (code) — otherwise a path
+ # like `.; rm -rf x` would be expanded into the shell before bash parses
+ # it. CodeRabbit #10.
+ OWN_PATH: ${{ inputs.path }}
+ OWN_FORMAT: ${{ inputs.format }}
+ OWN_FAIL_ON_FINDING: ${{ inputs.fail-on-finding }}
+ run: |
+ args=(--root "${{ github.action_path }}" --format "$OWN_FORMAT")
+ if [ "$OWN_FAIL_ON_FINDING" = "true" ]; then
+ args+=(--fail-on-finding)
+ fi
+ "${{ github.action_path }}/scripts/own-check.sh" "${args[@]}" -- "$OWN_PATH"
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index db1e1096..d10cdab4 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -156,3 +156,4 @@ own scan. Label them as estimates wherever they appear.
| [P-010](proposals/P-010-type-disciplines.md) | Richer type disciplines (`Own.Types`) | P2/horizon | draft |
| [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 | draft |
+| [P-013](proposals/P-013-distribution-surface.md) | Distribution surface (CI Action + dotnet tool) | enabling | v0 built |
diff --git a/docs/proposals/P-013-distribution-surface.md b/docs/proposals/P-013-distribution-surface.md
new file mode 100644
index 00000000..f3248440
--- /dev/null
+++ b/docs/proposals/P-013-distribution-surface.md
@@ -0,0 +1,93 @@
+# P-013 — Distribution surface: how people actually run Own.NET on C#
+
+- **Status:** v0 built (CI/Action + dotnet tool)
+- **Depends on:** P-001 (the C# → OwnIR extractor) and the core CLI
+ (`python -m ownlang ownir`). Sibling of **P-011** (editor tooling for the
+ `.own` DSL — a *different* direction; see "Not the same as P-011" below).
+ Feeds **P-012** (the mining pipeline reuses the same repo-scan).
+
+## Motivation
+
+The pipeline `*.cs → extractor → facts.json → core → finding @ C# line` has
+worked end-to-end in CI since P-001, but only against a hardcoded list of sample
+files. Nobody outside this repo can *run* it. The question "how will people use
+this?" has three candidate answers, and they cost wildly different amounts — so
+the first job is to pick the one that fits the architecture, not the one that
+sounds most impressive.
+
+The load-bearing constraint is the ROADMAP's **"one checker"**: the Python core
+is the single source of truth, and every frontend only *produces or consumes*
+OwnIR facts. That rule decides the surface for us.
+
+## The three surfaces (cost order)
+
+| Surface | What the user sees | Cost | Fits "one checker"? |
+| --- | --- | --- | --- |
+| **CI / CLI gate** | a red check + PR annotations | low (≈ done in CI) | ✅ ideal — Python already runs here |
+| **MSBuild diagnostics → VS Error List** | findings in VS, no extension | low–medium | ✅ text in a parseable format |
+| **Native Roslyn `DiagnosticAnalyzer`** | live squiggles in the IDE | high | ❌ conflicts (see below) |
+
+A native analyzer runs **in-process** inside `dotnet build` / the IDE. It would
+have to either (a) reimplement the analysis in C# — a *second checker* that
+drifts, the project's own meta-irony — or (b) shell out to Python on every
+keystroke, which is slow, fragile, and needs a Python runtime on every dev
+machine. So the obstacle to the IDE-native path is **architectural, not effort**.
+The CI/CLI surface, by contrast, is where Python already lives and where "one
+checker" is free.
+
+Decision: **ship the CI/CLI surface first**, expose the same findings in the
+MSBuild format so they *also* light up the VS Error List without an analyzer,
+and defer the native analyzer until (if ever) the core itself moves to .NET.
+
+## Scope (v0 — built)
+
+- **`python -m ownlang ownir facts.json --format {human,github,msbuild}`.** The
+ finding renderer lives in the core (`ownlang/ownir.py`), so the wrappers stay
+ thin and there is exactly one place that decides what a finding says:
+ - `human` — the existing CLI line (unchanged; the default).
+ - `github` — a `::error file=…,line=…,title=OWN001::…` workflow command;
+ GitHub renders it inline on the PR diff. Metacharacters are escaped.
+ - `msbuild` — `file(line): error OWN001: …`, which `dotnet build` and the VS
+ Error List parse — in-IDE findings with no extension.
+- **Repo-walk in the extractor.** `ownsharp-extract
` now recurses for
+ `*.cs`, skipping `bin`/`obj`/`.git`/`node_modules`/`packages` and generated
+ 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.
+- **`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`.
+- **`dotnet tool`** — the extractor csproj is `PackAsTool`
+ (`dotnet tool install --global OwnSharp.Extractor` → `ownsharp-extract`).
+ Honest caveat: the tool is only the C# *extractor*; the verdict is still the
+ Python core. The script/Action are the complete product.
+
+## Not the same as P-011
+
+P-011 makes the **`.own` DSL** a first-class editor language (coloring,
+squiggles for `.own` diagnostics) — input *to* the checker. P-013 is the
+opposite direction: feed **C#** in, get findings out. Easy to conflate; they do
+not overlap.
+
+## Non-goals
+
+- **A native Roslyn analyzer in v0** — deferred for the architectural reason
+ above, not the effort. Revisit only if the core moves to .NET.
+- **Wiring a 100-repo scan as a blocking gate** — that is P-012's offline job,
+ not this per-repo check.
+- **A second checker anywhere.** The wrappers never decide a verdict.
+- Publishing to the GitHub Marketplace / NuGet.org, SHA-pinning, signed releases
+ — packaging hardening, deferred to a release pass.
+
+## 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.
+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`) +
+ Marketplace listing? (Ties into the packaging-hardening pass.)
diff --git a/examples/ci/own-check.yml b/examples/ci/own-check.yml
new file mode 100644
index 00000000..eac8c791
--- /dev/null
+++ b/examples/ci/own-check.yml
@@ -0,0 +1,28 @@
+# Example consumer workflow — copy into a .NET repo's .github/workflows/.
+#
+# Drops the Own.NET resource-leak check onto every pull request: the Roslyn
+# extractor scans the repo's C#, the Python core produces the verdict, and any
+# leak shows up as an inline annotation on the PR diff (and fails the check).
+#
+# Pin the action to a released tag (e.g. @v0.1.0) rather than @main for stability.
+
+name: Own.NET leak check
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read # the check only reads the code
+
+jobs:
+ own-check:
+ name: resource-leak check
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: PhysShell/own.net@main
+ with:
+ path: .
+ format: github
+ fail-on-finding: "true"
diff --git a/frontend/roslyn/OwnSharp.Extractor/OwnSharp.Extractor.csproj b/frontend/roslyn/OwnSharp.Extractor/OwnSharp.Extractor.csproj
index 8b95e816..4f3c364d 100644
--- a/frontend/roslyn/OwnSharp.Extractor/OwnSharp.Extractor.csproj
+++ b/frontend/roslyn/OwnSharp.Extractor/OwnSharp.Extractor.csproj
@@ -6,6 +6,18 @@
enable
enable
ownsharp-extract
+
+
+ true
+ ownsharp-extract
+ OwnSharp.Extractor
+ 0.1.0
+ OwnSharp Roslyn extractor: scans C# for lifetime/resource leak facts (events, timers, IDisposable, ArrayPool) for the Own.NET core to check.
diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs
index eca27c30..7809d148 100644
--- a/frontend/roslyn/OwnSharp.Extractor/Program.cs
+++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs
@@ -12,27 +12,82 @@
// tagged resource=timer (WPF002) and counts as released if the timer's receiver
// also has a `.Stop()` call (e.g. `_timer.Stop()` in Dispose).
//
-// Usage: ownsharp-extract [more.cs ...] [-o facts.json]
+// Usage: ownsharp-extract [more ...] [-o facts.json]
+//
+// 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).
using System.Text.Json;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
-var inputs = new List();
+var rawInputs = new List();
string? outPath = null;
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "-o" && i + 1 < args.Length) outPath = args[++i];
- else inputs.Add(args[i]);
+ else rawInputs.Add(args[i]);
}
-if (inputs.Count == 0)
+if (rawInputs.Count == 0)
{
- Console.Error.WriteLine("usage: ownsharp-extract [...] [-o facts.json]");
+ Console.Error.WriteLine("usage: ownsharp-extract [...] [-o facts.json]");
return 2;
}
+// A path segment we never scan: build output, VCS, and vendored trees.
+static bool IsSkippedDir(string seg) =>
+ seg is "bin" or "obj" or ".git" or ".vs" or "node_modules" or "packages";
+
+// Generated C# the author did not write (and cannot fix): skip it.
+static bool IsGenerated(string path) =>
+ path.EndsWith(".g.cs", StringComparison.Ordinal)
+ || path.EndsWith(".Designer.cs", StringComparison.Ordinal)
+ || path.EndsWith(".AssemblyInfo.cs", StringComparison.Ordinal);
+
+static bool IsSkipped(string path)
+{
+ foreach (var seg in path.Split('/', '\\'))
+ if (IsSkippedDir(seg)) return true;
+ 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).
+static IEnumerable Expand(IEnumerable roots)
+{
+ var opts = new EnumerationOptions
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = true,
+ };
+ foreach (var p in roots)
+ {
+ if (Directory.Exists(p))
+ {
+ foreach (var f in Directory.EnumerateFiles(p, "*.cs", opts))
+ if (!IsSkipped(f))
+ yield return f;
+ }
+ else
+ {
+ yield return p;
+ }
+ }
+}
+
+// A finding's file is reported relative to the current directory (the repo root
+// in CI / under the Action), with forward slashes — so a GitHub annotation or an
+// MSBuild diagnostic points at the right file even when two files share a name.
+static string Rel(string path) =>
+ Path.GetRelativePath(Directory.GetCurrentDirectory(), path).Replace('\\', '/');
+
+var inputs = Expand(rawInputs).Distinct().ToList();
+
static bool IsHandler(ExpressionSyntax rhs) =>
rhs is IdentifierNameSyntax || rhs is MemberAccessExpressionSyntax;
@@ -75,8 +130,26 @@ t is "IDisposable" or "IAsyncDisposable" or "CancellationTokenSource"
foreach (var path in inputs)
{
- var text = File.ReadAllText(path);
- var file = Path.GetFileName(path);
+ // Defensive: an explicit input that is not a readable file (a directory
+ // passed by mistake, a deleted path) is skipped with a note, never an
+ // unhandled exception that aborts the whole scan.
+ if (!File.Exists(path))
+ {
+ Console.Error.WriteLine($"ownsharp-extract: skipping (not a file): {path}");
+ continue;
+ }
+ string text;
+ try
+ {
+ text = File.ReadAllText(path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // A locked/unreadable file is skipped with a note, not an abort.
+ Console.Error.WriteLine($"ownsharp-extract: skipping unreadable file: {path} ({ex.Message})");
+ continue;
+ }
+ var file = Rel(path);
var root = CSharpSyntaxTree.ParseText(text, path: path).GetRoot();
foreach (var cls in root.DescendantNodes().OfType())
diff --git a/frontend/roslyn/README.md b/frontend/roslyn/README.md
index 5b2423f3..105af69b 100644
--- a/frontend/roslyn/README.md
+++ b/frontend/roslyn/README.md
@@ -24,6 +24,45 @@ python -m ownlang ownir facts.json
# (OrdersViewModel unsubscribes in Dispose -> nothing reported)
```
+## 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
+hand. It scans a directory (recursively, skipping `bin`/`obj`/generated files):
+
+```bash
+# 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: …`, which `dotnet build` and 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`):
+
+```yaml
+- 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.
+
+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](../../docs/proposals/P-013-distribution-surface.md).
+
## Scope / honesty
This sandbox has no local `dotnet`, so the extractor is built and run only in CI
diff --git a/ownlang/__main__.py b/ownlang/__main__.py
index 382ba69d..d24bf512 100644
--- a/ownlang/__main__.py
+++ b/ownlang/__main__.py
@@ -6,6 +6,10 @@
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
+
+`--format` (ownir only) selects the finding surface: `human` (default CLI line),
+`github` (CI annotations on the PR diff), or `msbuild` (VS Error List).
Exit code is non-zero if any error-level diagnostic was produced.
"""
@@ -181,32 +185,77 @@ def _read(path: str) -> str:
return f.read()
-def cmd_ownir(path: str) -> int:
+def cmd_ownir(path: str, fmt: str = "human") -> int:
"""Check OwnIR facts (extracted from real C# by the Roslyn frontend) through
- the same core, surfacing findings at their C# locations (P-001)."""
- from .ownir import OwnIRError, check_facts, load
+ the same core, surfacing findings at their C# locations (P-001). `fmt`
+ selects the surface: human (CLI), github (CI annotations), msbuild (VS)."""
+ from .ownir import OwnIRError, check_facts, load, render_finding
try:
findings = check_facts(load(path))
except OwnIRError as e:
# bad facts / a drifted contract: a clear one-liner, not a traceback.
print(f"{path}: error: {e}", file=sys.stderr)
return 2
+ # In a machine format, stdout carries only the annotations/diagnostics a host
+ # (GitHub, MSBuild/VS) parses; the human summary goes to stderr so it cannot
+ # pollute that stream.
+ machine = fmt in {"github", "msbuild"}
+ summary_to = sys.stderr if machine else sys.stdout
for f in findings:
- print(f.render())
+ print(render_finding(f, fmt))
if not findings:
- print(f"{path}: ok — no subscription leaks found")
+ print(f"{path}: ok — no subscription leaks found", file=summary_to)
n = len(findings)
- print(f"\n{n} finding{'s' if n != 1 else ''}.")
+ print(f"\n{n} finding{'s' if n != 1 else ''}.", file=summary_to)
return 1 if findings else 0
+_FORMATS = {"human", "github", "msbuild"}
+
+
def main(argv: list[str]) -> int:
- if len(argv) < 2 or argv[0] not in {"check", "emit", "cfg", "report", "ownir"}:
+ if not argv or argv[0] not in {"check", "emit", "cfg", "report", "ownir"}:
print(__doc__)
return 2
- cmd, path = argv[0], argv[1]
+ 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"
+ 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
+ continue
+ positional.append(a)
+ i += 1
+ # exactly one positional (the path/file); zero or extra args is a usage error
+ # (a silently-ignored extra arg hides a caller mistake).
+ if len(positional) != 1:
+ print(__doc__)
+ return 2
+ 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)
+ return 2
+ path = positional[0]
+ if cmd == "ownir":
+ return cmd_ownir(path, fmt)
return {"check": cmd_check, "emit": cmd_emit, "cfg": cmd_cfg,
- "report": cmd_report, "ownir": cmd_ownir}[cmd](path)
+ "report": cmd_report}[cmd](path)
if __name__ == "__main__":
diff --git a/ownlang/ownir.py b/ownlang/ownir.py
index b2468ceb..bca376c7 100644
--- a/ownlang/ownir.py
+++ b/ownlang/ownir.py
@@ -71,6 +71,18 @@ class OwnIRError(ValueError):
driver turns it into a clear one-line error rather than a traceback."""
+def _esc_data(s: str) -> str:
+ """Escape a GitHub workflow-command message (the text after `::`). Per the
+ Actions command spec, only `%`, CR and LF are special there."""
+ return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
+
+
+def _esc_prop(s: str) -> str:
+ """Escape a GitHub workflow-command property value (`file=`, `title=`).
+ Property values additionally treat `:` and `,` as separators."""
+ return _esc_data(s).replace(":", "%3A").replace(",", "%2C")
+
+
_PRELUDE = (
'resource Subscription {\n'
' acquire Subscribe\n'
@@ -125,6 +137,32 @@ def render(self) -> str:
return (f"{self.file}:{self.line}: error: [{self.code}] "
f"{self.message} [resource: {self.kind}]")
+ def render_github(self) -> str:
+ """A GitHub Actions workflow annotation. Printed on a CI step's stdout,
+ GitHub renders it as an inline error on the PR diff at the C# location.
+ `title` carries the OWN code; the message keeps the [resource:] tag."""
+ msg = f"[{self.code}] {self.message} [resource: {self.kind}]"
+ return (f"::error file={_esc_prop(self.file)},line={self.line},"
+ f"title={_esc_prop(self.code)}::{_esc_data(msg)}")
+
+ def render_msbuild(self) -> str:
+ """The canonical MSBuild diagnostic format `file(line): error CODE: msg`.
+ `dotnet build` and the Visual Studio Error List parse exactly this, so
+ the findings surface in-IDE without a Roslyn analyzer — one checker, not
+ a second one reimplemented in C#."""
+ return (f"{self.file}({self.line}): error {self.code}: "
+ f"{self.message} [resource: {self.kind}]")
+
+
+def render_finding(f: Finding, fmt: str) -> str:
+ """Render a finding in one of the supported surfaces: `human` (the default
+ CLI line), `github` (CI annotation), or `msbuild` (VS Error List)."""
+ if fmt == "github":
+ return f.render_github()
+ if fmt == "msbuild":
+ return f.render_msbuild()
+ return f.render()
+
def load(path: str) -> dict[str, Any]:
"""Load and shape-check an OwnIR facts file (it is external input — a
diff --git a/scripts/own-check.sh b/scripts/own-check.sh
new file mode 100755
index 00000000..18b5e227
--- /dev/null
+++ b/scripts/own-check.sh
@@ -0,0 +1,74 @@
+#!/usr/bin/env bash
+#
+# own-check — run the Own.NET C# leak check over a path.
+#
+# Chains the two halves of the P-001 pipeline into one command:
+#
+# *.cs --[OwnSharp.Extractor (Roslyn)]--> facts.json --[python -m ownlang ownir]--> findings
+#
+# This is the body of the composite GitHub Action (action.yml) and also a
+# standalone local command. There is one checker — the Python core; the C# side
+# only extracts facts.
+#
+# Usage:
+# scripts/own-check.sh [--format human|github|msbuild] [--fail-on-finding]
+# [--root ] [--] [more ...]
+#
+# Defaults: --format human, scans ".", does not fail the shell on findings,
+# --root is the repo this script lives in. With --fail-on-finding the exit code
+# is the core's (1 = leaks found). A hard error (bad facts) always exits non-zero.
+#
+# Requirements: a .NET SDK (`dotnet`) and Python 3.11+ on PATH.
+
+set -euo pipefail
+
+root=""
+format="human"
+fail_on_finding=0
+paths=()
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --root)
+ [[ $# -ge 2 ]] || { echo "own-check: --root requires a value" >&2; exit 2; }
+ root="$2"; shift 2 ;;
+ --format)
+ [[ $# -ge 2 ]] || { echo "own-check: --format requires a value" >&2; exit 2; }
+ format="$2"; shift 2 ;;
+ --fail-on-finding) fail_on_finding=1; shift ;;
+ --) shift; while [[ $# -gt 0 ]]; do paths+=("$1"); shift; done ;;
+ -h|--help) sed -n '2,30p' "$0"; exit 0 ;;
+ *) paths+=("$1"); shift ;;
+ esac
+done
+
+# Default root = the Own.NET checkout this script lives in (scripts/..).
+if [[ -z "$root" ]]; then
+ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+fi
+if [[ ${#paths[@]} -eq 0 ]]; then
+ paths=(".")
+fi
+
+extractor="$root/frontend/roslyn/OwnSharp.Extractor"
+facts="$(mktemp)"
+trap 'rm -f "$facts"' EXIT
+
+# Stage 1: extract facts. dotnet's build/run chatter goes to stderr so stdout
+# stays clean for the host-parseable findings (-o writes the facts to a file).
+dotnet run --project "$extractor" -- "${paths[@]}" -o "$facts" 1>&2
+
+# Stage 2: the one checker produces the verdict at the C# location.
+set +e
+PYTHONPATH="$root" python -m ownlang ownir "$facts" --format "$format"
+rc=$?
+set -e
+
+# rc: 0 = clean, 1 = findings, >=2 = a hard error (bad facts / drifted contract).
+if [[ "$fail_on_finding" -eq 1 ]]; then
+ exit "$rc"
+fi
+if [[ "$rc" -ge 2 ]]; then
+ exit "$rc"
+fi
+exit 0
diff --git a/tests/test_ownir.py b/tests/test_ownir.py
index 6ef25089..ee354643 100644
--- a/tests/test_ownir.py
+++ b/tests/test_ownir.py
@@ -25,7 +25,15 @@
import tempfile
-from ownlang.ownir import OWNIR_VERSION, OwnIRError, check_facts, load, to_own
+from ownlang.ownir import (
+ OWNIR_VERSION,
+ Finding,
+ OwnIRError,
+ check_facts,
+ load,
+ render_finding,
+ to_own,
+)
from ownlang.parser import parse
_FIXTURE = os.path.join(os.path.dirname(__file__), "fixtures", "ownir",
@@ -226,6 +234,36 @@ def run() -> int:
if "[resource: disposable]" not in l0.render():
fails.append(f"local finding missing kind tag: {l0.render()!r}")
+ # --- output surfaces (Уровень 1): the same finding renders for a human, a
+ # GitHub annotation, and an MSBuild/VS Error List line. The format lives
+ # in the core (one checker), so the Action/script stay thin wrappers.
+ fnd = Finding(file="src/A.cs", line=42, code="OWN001", component="A",
+ event="bus.X", handler="OnX", message="event 'bus.X' leaks (leak)",
+ kind="subscription token")
+ checks += 1
+ gh = render_finding(fnd, "github")
+ if not gh.startswith("::error file=src/A.cs,line=42,title=OWN001::"):
+ fails.append(f"github render wrong prefix: {gh!r}")
+ if "leaks (leak) [resource: subscription token]" not in gh:
+ fails.append(f"github render missing message/tag: {gh!r}")
+ checks += 1
+ mb = render_finding(fnd, "msbuild")
+ if mb != ("src/A.cs(42): error OWN001: event 'bus.X' leaks (leak) "
+ "[resource: subscription token]"):
+ fails.append(f"msbuild render wrong: {mb!r}")
+ checks += 1
+ # an unknown format falls back to the human line (no crash).
+ if render_finding(fnd, "bogus") != fnd.render():
+ fails.append("unknown format should fall back to human render")
+ checks += 1
+ # GitHub command metacharacters in a path/message are escaped, never raw.
+ nasty = Finding(file="a,b:c.cs", line=1, code="OWN001", component="C",
+ event="e", handler="h", message="line1\nline2 50% off",
+ kind="timer")
+ g2 = render_finding(nasty, "github")
+ if "a%2Cb%3Ac.cs" not in g2 or "%0A" not in g2 or "50%25 off" not in g2:
+ fails.append(f"github render did not escape metacharacters: {g2!r}")
+
for f in fails:
print(f"OWNIR FAIL: {f}")
print(f"ownir: {checks - len(fails)}/{checks} bridge checks passed")