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
23 changes: 22 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -996,7 +996,28 @@ jobs:
diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \
<(jq -S . "$RUNNER_TEMP/proj-facts-flag.json") \
|| { echo "FAIL: --project flag and positional .csproj emit different OwnIR facts"; exit 1; }
echo "OK: .csproj input resolves to its source set and feeds the core identically (positional == --project, fact-level parity)"
# the `extract` verb + `--out` long form must resolve to the same facts as the bare form.
dotnet run --project frontend/roslyn/OwnSharp.Extractor -- \
extract --project frontend/roslyn/project-input-sample/ProjectInputSample.csproj \
--out "$RUNNER_TEMP/proj-facts-verb.json"
diff <(jq -S . "$RUNNER_TEMP/proj-facts.json") \
<(jq -S . "$RUNNER_TEMP/proj-facts-verb.json") \
|| { echo "FAIL: 'extract --out' verb form disagrees with the bare form"; exit 1; }
echo "OK: .csproj input resolves to its source set and feeds the core identically (bare == --project == 'extract --out', fact-level parity)"
# explain: the diagnostic-catalogue CLI surface lives in the core (one checker). Smoke
# it end-to-end — explain a code, and harvest+explain every code in a real findings file.
- name: explain command (code + --json harvest)
run: |
python -m ownlang explain OWN001 | grep -q "Fix:" \
|| { echo "FAIL: explain OWN001 missing a Fix line"; exit 1; }
# ownir exits 1 when findings exist (expected — the sample leaks); only 2+ is a real
# error (bad facts / emit regression). Tolerate 1, surface 2+, so a genuine SARIF
# generation failure is not masked by a blanket `|| true` (CodeRabbit).
rc=0; python -m ownlang ownir "$RUNNER_TEMP/proj-facts.json" --format sarif > "$RUNNER_TEMP/proj.sarif" || rc=$?
[ "$rc" -le 1 ] || { echo "FAIL: SARIF generation errored (rc=$rc)"; exit 1; }
python -m ownlang explain --json "$RUNNER_TEMP/proj.sarif" | grep -q "OWN001" \
|| { echo "FAIL: explain --json did not harvest OWN001 from the SARIF log"; exit 1; }
echo "OK: explain answers a code and harvests codes from a real findings/SARIF file"

# The distribution surface (Уровень 1): the own-check.sh orchestrator walks a
# directory of real C# and prints findings in the host-parseable formats the
Expand Down
34 changes: 30 additions & 4 deletions docs/notes/roslyn-tools-and-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,37 @@ short of the full project/package/reference graph — that is the
`wpf-extractor` CI step pins the `.csproj` path to a golden (positional ==
`--project`).

## Next PR (concrete, no scope creep)
## The `extract` / `check` / `explain` surface (landed)

Firm up the rest of the advertised CLI: `extract` / `check` / `explain`
subcommands (`System.CommandLine`), and `--ref-dir`-from-project-`bin`
auto-derivation once `ProjectDependencies`-style graph reading lands.
The advertised three-verb CLI is realised — but split to where the architecture
puts each, not bolted onto one binary (there is one checker; the C# tool only
emits facts):

```bash
ownsharp-extract extract --project App.csproj --out facts.ownir.json # C# extractor (facts)
scripts/own-check.sh --format sarif -- App.sln > findings.sarif # `check`: the orchestrator
python -m ownlang explain OWN001 # `explain`: the core's catalogue
python -m ownlang explain --json findings.sarif # (explain every code a run produced)
```

(`explain --json` harvests diagnostic *codes* from the checker's findings/SARIF
output — not from `facts.ownir.json`, which carries extractor facts, no codes.)

- **`extract`** is an optional leading verb on the C# tool (the bare form stays the
default), plus `--out` as the long twin of `-o`. The tool's one job.
- **`check`** is *not* a C# verb: `own-check.sh` already chains the extractor and the
core, and since the project/solution PR it takes a `.csproj`/`.sln` directly.
Re-implementing a checker in C# is the rejected path.
- **`explain`** lives in the core next to the diagnostic catalogue
(`ownlang/diagnostics.py`: `TITLES` + `EXPLANATIONS`): it prints what a code means,
why it fires, and how to fix it; `--json` harvests every code from a findings/SARIF
file so you can explain exactly what a run produced.

A `System.CommandLine` migration of the C# tool (auto `--help`, validation) and
`--ref-dir`-from-project-`bin` auto-derivation remain the next polish — deferred over
a blind framework swap, since the extractor builds only in CI here.

## Earlier next-PR sketch (kept for the record)

```bash
ownsharp extract --project App.csproj --out facts.ownir.json
Expand Down
39 changes: 26 additions & 13 deletions frontend/roslyn/OwnSharp.Extractor/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@
// `.Stop()` call. The IDisposable/pool/local detectors remain syntactic for now
// (P-014 rollout: the event fact goes type-aware first).
//
// Usage: ownsharp-extract <file.cs | dir | *.csproj | *.sln> [more ...] [-o facts.json]
// ownsharp-extract --project App.csproj (flag twin of the positional form)
// Usage: ownsharp-extract [extract] <file.cs | dir | *.csproj | *.sln> [more ...] [-o|--out facts.json]
// ownsharp-extract extract --project App.csproj --out facts.json
// ownsharp-extract --solution App.sln
//
// `extract` is an optional leading verb (the tool's one job); the bare form is the
// default. The sibling `check`/`explain` verbs are NOT here — `check` is the
// own-check orchestrator that chains this + the core, and `explain` is `python -m
// ownlang explain OWN001`. One checker: the C# tool only emits facts.
//
// Inputs may be .cs files, directories, a .csproj, or a .sln. 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
Expand Down Expand Up @@ -70,25 +75,33 @@
// run that did not request it (the other config — emitEvents/flowLocals/reportStats — are locals,
// re-initialized each call, so they need no reset; only this static one does). CodeRabbit.
BodyThrowEdges = false;
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "-o" && i + 1 < args.Length) outPath = args[++i];
// `extract` verb: the tool's one job is extraction, so an optional leading `extract`
// makes the advertised `ownsharp-extract extract --project App.csproj --out facts.json`
// UX real while the bare form (no verb) stays the default and back-compatible. (The
// sibling `check`/`explain` verbs live where the architecture puts them — `check` is the
// own-check orchestrator, `explain` is `python -m ownlang explain`; the C# tool is not a
// second checker.) A different first token (a path/flag) is left untouched as input.
var args0 = args.Length > 0 && args[0] == "extract" ? args[1..] : args;
for (int i = 0; i < args0.Length; i++)
{
// `--out FILE` is the long-form twin of `-o FILE` (the advertised `extract --out` UX).
if ((args0[i] == "-o" || args0[i] == "--out") && i + 1 < args0.Length) outPath = args0[++i];
// `--project <App.csproj>` / `--solution <App.sln>`: the explicit-flag twin of passing the
// project/solution as a positional input (both resolve through Expand). The flag form matches
// the advertised `ownsharp extract --project ...` UX borrowed from the roslyn-tools CLI shape;
// the positional form keeps the command unambiguous next to dotnet's own `run --project`.
else if ((args[i] == "--project" || args[i] == "--solution") && i + 1 < args.Length) rawInputs.Add(args[++i]);
else if (args[i] == "--ref-dir" && i + 1 < args.Length) refDirs.Add(args[++i]);
else if (args[i] == "--no-event-leaks") emitEvents = false;
else if (args[i] == "--flow-locals") flowLocals = true;
else if (args[i] == "--body-throw-edges") BodyThrowEdges = true;
else if (args[i] == "--stats") reportStats = true;
else rawInputs.Add(args[i]);
else if ((args0[i] == "--project" || args0[i] == "--solution") && i + 1 < args0.Length) rawInputs.Add(args0[++i]);
else if (args0[i] == "--ref-dir" && i + 1 < args0.Length) refDirs.Add(args0[++i]);
else if (args0[i] == "--no-event-leaks") emitEvents = false;
else if (args0[i] == "--flow-locals") flowLocals = true;
else if (args0[i] == "--body-throw-edges") BodyThrowEdges = true;
else if (args0[i] == "--stats") reportStats = true;
else rawInputs.Add(args0[i]);
}

if (rawInputs.Count == 0)
{
Console.Error.WriteLine("usage: ownsharp-extract <file.cs | dir | *.csproj | *.sln> [...] [-o facts.json] [--ref-dir <bin-dir>]");
Console.Error.WriteLine("usage: ownsharp-extract [extract] <file.cs | dir | *.csproj | *.sln> [...] [-o|--out facts.json] [--ref-dir <bin-dir>]");
return 2;
}

Expand Down Expand Up @@ -2851,7 +2864,7 @@
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
.Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
.ToList();
var refNames = new HashSet<string>(tpa.Select(Path.GetFileName), StringComparer.OrdinalIgnoreCase);

Check warning on line 2867 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / P-014 Tier B — external reference resolution (--ref-dir)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2867 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / P-014 Tier B — external reference resolution (--ref-dir)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2867 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check SARIF -> GitHub code scanning (dog-food)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2867 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check SARIF -> GitHub code scanning (dog-food)

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2867 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2867 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / C# leak extractor (Roslyn) -> OwnIR -> core

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2867 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / own-check repo scan (github + msbuild) + composite action

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.

Check warning on line 2867 in frontend/roslyn/OwnSharp.Extractor/Program.cs

View workflow job for this annotation

GitHub Actions / C# leak extractor (Roslyn) -> OwnIR -> core

Argument of type 'IEnumerable<string?>' cannot be used for parameter 'collection' of type 'IEnumerable<string>' in 'HashSet<string>.HashSet(IEnumerable<string> collection, IEqualityComparer<string>? comparer)' due to differences in the nullability of reference types.
var references = tpa.Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList();
// P-004 WPF profile: widen the reference set with assemblies named by the
// OWN_EXTRA_REF_DIRS env var (colon-separated dirs) — e.g. the WindowsDesktop ref
Expand Down
10 changes: 10 additions & 0 deletions frontend/roslyn/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,18 @@ project or solution the way the borrowed roslyn-tools CLI shape advertises:
dotnet run --project OwnSharp.Extractor -- App.csproj -o facts.json # positional
dotnet run --project OwnSharp.Extractor -- --project App.csproj -o facts.json
dotnet run --project OwnSharp.Extractor -- --solution App.sln -o facts.json
dotnet run --project OwnSharp.Extractor -- extract --project App.csproj --out facts.json # explicit verb
```

`extract` is an optional leading verb (the tool's one job; the bare form is the
default), and `--out` is the long twin of `-o`. The sibling verbs live where the
architecture puts them — `check` is `scripts/own-check.sh` (which chains this + the
core and accepts a `.csproj`/`.sln`), and `explain` is in the core:
`python -m ownlang explain OWN001` (or `--json findings.sarif` — the checker's
findings/SARIF output — to explain every code a run produced; note `facts.json`
holds extractor facts, not diagnostic codes). One checker: the C# tool only emits
facts.

A `.csproj` resolves to its source set by scanning the project's directory for
`*.cs` (the SDK default-compile-items behaviour) plus any concrete linked
`<Compile Include="..\Shared\Foo.cs" />` outside the project tree — while honouring
Expand Down
107 changes: 106 additions & 1 deletion ownlang/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
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|sarif
python -m ownlang explain OWN001 [DI002 ...] # explain diagnostic code(s): what/why/fix
python -m ownlang explain --json findings.json # explain every code in a findings/SARIF file

`explain` is the diagnostic catalogue side of the CLI (the `ownsharp explain` the
roslyn-tools-shaped surface advertises): it prints what a code means, why it fires,
and how to fix it. It lives in the core, next to the catalogue, because there is one
checker — the C# extractor emits facts, it does not own the diagnostics.

`--format` (ownir only) selects the finding surface: `human` (default CLI line),
`github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif`
Expand All @@ -23,6 +30,7 @@

from __future__ import annotations

import re
import sys
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -192,6 +200,82 @@ def _read(path: str) -> str:
return f.read()


# A diagnostic code is OWN/WPF/DI followed by three digits. Used to validate an
# `explain` argument and to harvest codes out of a findings/SARIF JSON file.
_CODE_RE = re.compile(r"^(OWN|WPF|DI)\d{3}$")


def _explain_one(code: str) -> str:
"""The explanation block for one code: its title and the long-form what/why/fix
(falling back to just the title when no long-form exists), or an 'unknown code'
line. Pure text so it is trivially testable."""
from .diagnostics import EXPLANATIONS, TITLES
code = code.upper()
title = TITLES.get(code)
body = EXPLANATIONS.get(code)
if title is None and body is None:
return f"{code}: unknown diagnostic code"
out = f"{code}: {title}" if title else code
if body:
out += "\n\n" + body
return out


def _codes_from_json(obj: object) -> list[str]:
"""Every distinct diagnostic code reachable in a decoded JSON value, in first-seen
order. Harvests the values of any `code`/`ruleId` key (so it reads a findings array,
a single finding, or a SARIF log's results) that look like a diagnostic code."""
seen: dict[str, None] = {}

def walk(node: object) -> None:
if isinstance(node, dict):
for key, val in node.items():
if key in {"code", "ruleId"} and isinstance(val, str) and _CODE_RE.match(val):
seen.setdefault(val, None)
else:
walk(val)
elif isinstance(node, list):
for item in node:
walk(item)

walk(obj)
return list(seen)


def cmd_explain(codes: list[str], json_path: str | None) -> int:
"""Explain diagnostic code(s): print what each means, why it fires, and how to fix
it. Codes come from the command line (`explain OWN001 DI002`) and/or are harvested
from a findings/SARIF JSON (`--json findings.json`), so you can explain exactly the
codes a run produced. Exit 2 on a usage error (no codes) or an unreadable JSON."""
import json
all_codes = list(codes)
if json_path is not None:
try:
with open(json_path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
print(f"explain: cannot read {json_path}: {e}", file=sys.stderr)
return 2
found = _codes_from_json(data)
if not found:
print(f"explain: no diagnostic codes found in {json_path}", file=sys.stderr)
return 2
# de-dupe against any codes already given on the command line, preserving order
for c in found:
if c not in all_codes:
all_codes.append(c)
if not all_codes:
print("explain: give a code (e.g. OWN001) or --json <findings.json>", file=sys.stderr)
return 2
from .diagnostics import EXPLANATIONS, TITLES
print("\n\n".join(_explain_one(c) for c in all_codes))
# If every requested code is unknown, that is almost certainly a typo — fail (2)
# rather than silently succeed. A mix of known + unknown still exits 0.
if all(c.upper() not in TITLES and c.upper() not in EXPLANATIONS for c in all_codes):
return 2
return 0


def cmd_ownir(path: str, fmt: str = "human", severity: str = "error",
verbosity: str = "normal") -> int:
"""Check OwnIR facts (extracted from real C# by the Roslyn frontend) through
Expand Down Expand Up @@ -261,10 +345,31 @@ def cmd_ownir(path: str, fmt: str = "human", severity: str = "error",


def main(argv: list[str]) -> int:
if not argv or argv[0] not in {"check", "emit", "cfg", "report", "ownir"}:
if not argv or argv[0] not in {"check", "emit", "cfg", "report", "ownir", "explain"}:
print(__doc__)
return 2
cmd = argv[0]
# `explain` has its own shape — zero-or-more code positionals plus an optional
# `--json <file>` — so it is handled before the single-positional path below.
if cmd == "explain":
codes: list[str] = []
json_path: str | None = None
rest = argv[1:]
i = 0
while i < len(rest):
a = rest[i]
if a == "--json":
if i + 1 >= len(rest):
print("--json requires a value", file=sys.stderr)
return 2
json_path, i = rest[i + 1], i + 2
continue
if a.startswith("--json="):
json_path, i = a.split("=", 1)[1], i + 1
continue
codes.append(a)
i += 1
return cmd_explain(codes, json_path)
# Pull the optional value-flags (`--format`/`--severity`/`--verbosity`, 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.
Expand Down
Loading
Loading