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
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

61 changes: 61 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
93 changes: 93 additions & 0 deletions docs/proposals/P-013-distribution-surface.md
Original file line number Diff line number Diff line change
@@ -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 <dir>` 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.)
28 changes: 28 additions & 0 deletions examples/ci/own-check.yml
Original file line number Diff line number Diff line change
@@ -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"
12 changes: 12 additions & 0 deletions frontend/roslyn/OwnSharp.Extractor/OwnSharp.Extractor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ownsharp-extract</AssemblyName>

<!-- Packable as a .NET tool: `dotnet pack`, then install it globally with
`dotnet tool install` (PackageId OwnSharp.Extractor) to get the
`ownsharp-extract` command. NOTE: the tool is only the C# extractor (it
emits OwnIR facts.json); the verdict still comes from the Python core
(`python -m ownlang ownir`). The own-check script / Action chain the
two. There is one checker, not two. -->
<PackAsTool>true</PackAsTool>
<ToolCommandName>ownsharp-extract</ToolCommandName>
<PackageId>OwnSharp.Extractor</PackageId>
<Version>0.1.0</Version>
<Description>OwnSharp Roslyn extractor: scans C# for lifetime/resource leak facts (events, timers, IDisposable, ArrayPool) for the Own.NET core to check.</Description>
</PropertyGroup>

<ItemGroup>
Expand Down
87 changes: 80 additions & 7 deletions frontend/roslyn/OwnSharp.Extractor/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file.cs> [more.cs ...] [-o facts.json]
// Usage: ownsharp-extract <file.cs | dir> [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<string>();
var rawInputs = new List<string>();
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 <file.cs> [...] [-o facts.json]");
Console.Error.WriteLine("usage: ownsharp-extract <file.cs | dir> [...] [-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<string> Expand(IEnumerable<string> 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;

Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
var root = CSharpSyntaxTree.ParseText(text, path: path).GetRoot();

foreach (var cls in root.DescendantNodes().OfType<ClassDeclarationSyntax>())
Expand Down
Loading
Loading