diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66e7c0c4..93126c43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/notes/roslyn-tools-and-cli.md b/docs/notes/roslyn-tools-and-cli.md index 15efb273..4adc7f81 100644 --- a/docs/notes/roslyn-tools-and-cli.md +++ b/docs/notes/roslyn-tools-and-cli.md @@ -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 diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 9264a43d..2f19b5ee 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -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 [more ...] [-o facts.json] -// ownsharp-extract --project App.csproj (flag twin of the positional form) +// Usage: ownsharp-extract [extract] [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 @@ -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 ` / `--solution `: 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 [...] [-o facts.json] [--ref-dir ]"); + Console.Error.WriteLine("usage: ownsharp-extract [extract] [...] [-o|--out facts.json] [--ref-dir ]"); return 2; } diff --git a/frontend/roslyn/README.md b/frontend/roslyn/README.md index c09716cb..5cb753af 100644 --- a/frontend/roslyn/README.md +++ b/frontend/roslyn/README.md @@ -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 `` outside the project tree — while honouring diff --git a/ownlang/__main__.py b/ownlang/__main__.py index a7a4fca0..b8432ddf 100644 --- a/ownlang/__main__.py +++ b/ownlang/__main__.py @@ -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` @@ -23,6 +30,7 @@ from __future__ import annotations +import re import sys from typing import TYPE_CHECKING @@ -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 ", 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 @@ -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 ` — 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. diff --git a/ownlang/diagnostics.py b/ownlang/diagnostics.py index 48417161..c05d7abd 100644 --- a/ownlang/diagnostics.py +++ b/ownlang/diagnostics.py @@ -80,6 +80,102 @@ class Severity(Enum): "OWN041": "call argument mismatch", # ---- C# front-end resolution coverage (P-014; advisory) ---- "OWN050": "declaring type unresolved -- leakage analysis skipped", + # ---- DI container lifetimes (P-006; emitted by the OwnIR bridge) ---- + "DI001": "captive dependency: a shorter-lived service is captured by a longer-lived one", + "DI002": "singleton captures a scoped service (captive dependency)", + "DI003": "singleton captures a transient service (captive dependency)", + "DI004": "scoped service resolved from the root provider (captured for the app lifetime)", + "DI005": "disposable transient resolved from a long-lived scope (delayed disposal)", +} + + +# Long-form `explain` text: a paragraph of "what this means / why it leaks / how +# to fix", keyed by code. The `explain` command (`python -m ownlang explain OWN001`) +# prints this; a code with no entry here falls back to its one-line TITLE, so the +# command always answers. Kept deliberately to the codes a user actually meets via +# the Roslyn extractor pipeline (subscription/disposable/DI), not the whole grammar. +EXPLANATIONS = { + "DI001": ( + "Captive dependency (the umbrella verdict): a longer-lived service holds a reference to a " + "shorter-lived one, so the shorter-lived instance is pinned to the longer life — its " + "intended per-scope/per-call semantics are lost and it may leak. DI002-DI005 are the " + "specific shapes (singleton->scoped, singleton->transient, scoped-from-root, " + "disposable-transient-from-a-long-scope).\n" + "Fix: don't capture the shorter-lived service directly — inject `IServiceScopeFactory`, " + "create a scope per use, and resolve from it (or align the lifetimes). A `Func` " + "factory also works but the built-in container does not auto-resolve `Func` — you must " + "register it (or use a container that does)." + ), + "OWN001": ( + "An owned resource is acquired but not released on every path out of its owner — " + "a possible leak. For a C# event, `target += handler` with no matching `target -= " + "handler` keeps the handler (and everything it captures) alive for as long as the " + "event source lives.\n" + "Fix: release on every path — unsubscribe (`-=`) in Dispose/Unloaded, dispose the " + "owned field in the owner's Dispose, or capture and dispose the IDisposable a " + "Subscribe() returns." + ), + "OWN002": ( + "A resource is used after it was released, so the access touches a freed/disposed " + "object.\nFix: move the use before the release, or do not release while the value is " + "still needed." + ), + "OWN003": ( + "A resource is released twice on some path (double dispose/return).\n" + "Fix: release on exactly one path; guard the second release or restructure so the " + "branches don't both release." + ), + "OWN009": ( + "A resource is used after a release that happens on only *some* paths, so whether the " + "value is live depends on the branch taken.\n" + "Fix: make release happen on all paths or none before the use, so the state is " + "unambiguous at the use site." + ), + "OWN014": ( + "A value escapes into a longer-lived region than its own (lifetime promotion) — e.g. a " + "ViewModel stored where it outlives the View that owns it.\n" + "Fix: keep the value within its region, or transfer ownership explicitly to the " + "longer-lived holder so its disposal is accounted for there." + ), + "OWN025": ( + "A full-length view (Span/Memory over the whole array) of a pooled buffer reaches past " + "the buffer's logical length — ArrayPool.Rent may return a larger array than requested.\n" + "Fix: slice to the logical length (`buf.AsSpan(0, len)`) before viewing the rented array." + ), + "OWN050": ( + "Advisory, not a leak verdict: the declaring type of a `+=`/`-=` (e.g. a third-party " + "WPF/DevExpress event) could not be resolved, so leakage analysis was skipped for it " + "rather than guessed (P-014 Tier A). It never fails a build.\n" + "Fix (to check it): give the extractor the type's assembly via `--ref-dir ` so the " + "SemanticModel can bind the event." + ), + "DI002": ( + "A singleton captures a scoped service: the scoped instance is pinned to the singleton " + "for the whole app lifetime, defeating per-scope (e.g. per-request) semantics and often " + "leaking a DbContext-like object.\n" + "Fix: don't inject the scoped service into the singleton — inject `IServiceScopeFactory`, " + "create a scope per use, and resolve the scoped service from it. (A registered `Func` " + "factory also works, but the built-in container does not auto-resolve `Func`.)" + ), + "DI003": ( + "A singleton captures a transient service: the transient is created once and lives for " + "the app lifetime, so its intended short life is lost.\n" + "Fix: resolve the transient per use from a scope (`IServiceScopeFactory`) instead of " + "holding the instance — or inject a `Func` factory you have registered (the built-in " + "container does not auto-resolve `Func`)." + ), + "DI004": ( + "A scoped service is resolved from the root provider, so it is captured for the whole " + "application lifetime instead of the intended scope.\n" + "Fix: resolve scoped services from a created scope (`IServiceScopeFactory.CreateScope()`), " + "not from the root provider." + ), + "DI005": ( + "A disposable transient is resolved from a long-lived scope (often the root): the " + "container tracks it and only disposes it when that scope ends, delaying disposal.\n" + "Fix: resolve disposable transients within a short-lived scope you dispose, or manage " + "their lifetime explicitly." + ), } diff --git a/tests/run_tests.py b/tests/run_tests.py index 39fa90ec..b9135b43 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1117,11 +1117,17 @@ def run() -> int: import test_diagnostics diag_rc = test_diagnostics.run() + # `explain` command: the diagnostic-catalogue CLI surface (text + exit codes + + # --json code harvest + DI catalogue coverage + dispatch). + import test_explain + explain_rc = test_explain.run() + return 1 if (failed or cg_fail or golden_fails or buffer_fails or escape_fails or branchy_fails or nest_fails or order_fails or helper_fails or cc_rc or pf_rc or gl_rc or co_rc or wpf_rc or lt_rc or loops_rc - or spec_rc or ownir_rc or own5_rc or rid_rc or diag_rc) else 0 + or spec_rc or ownir_rc or own5_rc or rid_rc or diag_rc + or explain_rc) else 0 if __name__ == "__main__": diff --git a/tests/test_explain.py b/tests/test_explain.py new file mode 100644 index 00000000..fb265348 --- /dev/null +++ b/tests/test_explain.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +`explain` command tests (the diagnostic-catalogue side of the CLI). + +Pins the contract of `python -m ownlang explain`: + + 1. A known code prints its title and long-form what/why/fix; a code with only a + title (no long-form) still answers with the title. + 2. An all-unknown request exits 2 (a typo'd code must not silently succeed); a + mix of known + unknown still exits 0. + 3. `--json` harvests every distinct code from a findings array AND a SARIF log + (`ruleId`), de-duped, in first-seen order, and unions with command-line codes. + 4. The DI catalogue the bridge emits (DI001-005) is covered, so a real run's + codes are all explainable. + +Run: python tests/test_explain.py + python tests/run_tests.py (folded into the suite aggregator) +""" + +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.__main__ import _codes_from_json, _explain_one, cmd_explain, main +from ownlang.diagnostics import EXPLANATIONS, TITLES + + +def _rc(*args: object) -> int: + """Call cmd_explain/main with stdout+stderr muted, returning only the exit code — + keeps the suite output clean (the printed explanations are exercised elsewhere).""" + fn, fnargs = args[0], args[1:] + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + return fn(*fnargs) # type: ignore[operator,no-any-return] + + +def _explain_text() -> list[str]: + """Cases on the pure `_explain_one` text builder.""" + fails: list[str] = [] + one = _explain_one("OWN001") + if "OWN001:" not in one or "Fix:" not in one: + fails.append("OWN001 explanation should carry the title and a Fix line") + # case-insensitive code input + if _explain_one("own001") != one: + fails.append("explain should be case-insensitive on the code") + # a title-only code (no long-form) still answers with the title + titleonly = next((c for c in TITLES if c not in EXPLANATIONS), None) + if titleonly is not None: + out = _explain_one(titleonly) + if not out.startswith(f"{titleonly}:") or TITLES[titleonly] not in out: + fails.append(f"title-only code {titleonly} should fall back to its title") + # an unknown code is named as such, not a crash + if "unknown" not in _explain_one("OWN999"): + fails.append("unknown code should be reported as unknown") + return fails + + +def _exit_codes() -> list[str]: + """`cmd_explain` exit-code contract.""" + fails: list[str] = [] + if _rc(cmd_explain, ["OWN001"], None) != 0: + fails.append("a known code should exit 0") + if _rc(cmd_explain, [], None) != 2: + fails.append("no codes and no --json should exit 2") + if _rc(cmd_explain, ["OWN999"], None) != 2: + fails.append("an all-unknown request should exit 2") + if _rc(cmd_explain, ["OWN001", "OWN999"], None) != 0: + fails.append("a mix of known + unknown should exit 0") + return fails + + +def _json_harvest() -> list[str]: + """`--json` code harvesting from findings arrays and SARIF logs.""" + fails: list[str] = [] + findings = [{"code": "OWN050", "file": "a.cs", "line": 3}, + {"code": "OWN001", "file": "b.cs", "line": 9}, + {"code": "OWN001", "file": "c.cs", "line": 1}] # dup -> collapsed + got = _codes_from_json(findings) + if got != ["OWN050", "OWN001"]: + fails.append(f"findings harvest should be first-seen, de-duped; got {got}") + sarif = {"runs": [{"results": [{"ruleId": "DI004"}, {"ruleId": "OWN001"}]}]} + got = _codes_from_json(sarif) + if got != ["DI004", "OWN001"]: + fails.append(f"SARIF ruleId harvest failed; got {got}") + # a non-code 'code' value (e.g. an HTTP code) must not be harvested + if _codes_from_json({"code": "404"}) != []: + fails.append("a non-diagnostic 'code' value must not be harvested") + + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, "f.json") + with open(p, "w", encoding="utf-8") as f: + json.dump(findings, f) + if _rc(cmd_explain, [], p) != 0: + fails.append("--json over real findings should exit 0") + # an unreadable / missing file is a usage error + if _rc(cmd_explain, [], os.path.join(d, "nope.json")) != 2: + fails.append("--json on a missing file should exit 2") + # a JSON with no codes is a usage error, not a silent success + empty = os.path.join(d, "empty.json") + with open(empty, "w", encoding="utf-8") as f: + json.dump({"unrelated": 1}, f) + if _rc(cmd_explain, [], empty) != 2: + fails.append("--json with no codes should exit 2") + return fails + + +def _di_catalogue() -> list[str]: + """Every DI code the bridge emits is explainable (title at minimum).""" + fails = [f"{c} has no title" for c in ("DI001", "DI002", "DI003", "DI004", "DI005") + if c not in TITLES] + return fails + + +def _dispatch() -> list[str]: + """The `main` arg parser routes `explain` (incl. --json=VALUE form).""" + fails: list[str] = [] + if _rc(main, ["explain", "OWN001"]) != 0: + fails.append("main(['explain','OWN001']) should exit 0") + if _rc(main, ["explain"]) != 2: + fails.append("main(['explain']) with no code should exit 2") + return fails + + +def run() -> int: + """Run every explain case; return 0/1.""" + fails: list[str] = [] + fails += _explain_text() + fails += _exit_codes() + fails += _json_harvest() + fails += _di_catalogue() + fails += _dispatch() + for f in fails: + print(f"EXPLAIN FAIL: {f}") + print(f"explain: {'PASS' if not fails else 'FAIL'} " + f"(text + exit codes + --json harvest + DI catalogue + dispatch)") + return 1 if fails else 0 + + +if __name__ == "__main__": + raise SystemExit(run())