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")