diff --git a/docs/notes/sarif-export.md b/docs/notes/sarif-export.md index 888c9ca2..432cea27 100644 --- a/docs/notes/sarif-export.md +++ b/docs/notes/sarif-export.md @@ -121,3 +121,22 @@ Still open: - **Per-rule `helpUri`.** Once a stable per-code docs anchor exists, point each `rules[]` entry at it (intentionally omitted now rather than link a 404). + +## The `.own` flow-diagnostic SARIF surface + +Everything above is the **C# / OwnIR** path (`ownir --format sarif` over +`ownir.Finding`). The direct `.own` checker (`python -m ownlang check`) grew its +own SARIF surface once the flow diagnostics started carrying structured evidence +(the execution-surfaces ADR, §3.1): `check file.own --format sarif` emits the same +SARIF 2.1.0 shape — one `Own.NET` `run`, a `rules[]` catalogue of the OWN codes +present, one `result` per diagnostic — via `ownlang/diag_sarif.py`. + +The point of the surface is the **evidence slice**: each diagnostic's +`Diagnostic.evidence` is projected through the *shared* `ownlang.evidence` +builders (`related_locations` / `code_flow`), so an OWN015 buffer escape rides a +`codeFlows` trace `allocated here -> escapes by return here`, an OWN001 leak points +at `acquired here`, and so on — the same `relatedLocations` + `codeFlows` +vocabulary the OwnIR path already speaks. A diagnostic with no evidence carries +neither key; a step with no resolvable line or file is dropped, so the log never +carries an empty `artifactLocation.uri`. `github`/`msbuild` stay OwnIR-only — +those are per-finding line renderers that need a `Finding`, not a `Diagnostic`. diff --git a/ownlang/__main__.py b/ownlang/__main__.py index b8432ddf..a93c3da9 100644 --- a/ownlang/__main__.py +++ b/ownlang/__main__.py @@ -2,6 +2,7 @@ Command-line driver for the OwnLang PoC. python -m ownlang check file.own # report ownership diagnostics + python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning) python -m ownlang emit file.own # check, then print generated C# python -m ownlang cfg file.own # dump the control-flow graph python -m ownlang report file.own # buffer storage report + .ownreport.json @@ -15,9 +16,12 @@ 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), +`--format` selects the finding surface. On `ownir`: `human` (default CLI line), `github` (CI annotations on the PR diff), `msbuild` (VS Error List), or `sarif` (a SARIF 2.1.0 log — GitHub code scanning, and the cross-tool oracle reads it too). +On `check` it is `human` (default) or `sarif` — the `.own` flow diagnostics as a +SARIF log carrying each finding's evidence slice (relatedLocations / codeFlows); +`github`/`msbuild` are ownir-only (they render a Finding, not a Diagnostic). `--severity` (ownir only) picks how the host shows a finding — `error` (default, fails a build / red check) or `warning` (advisory). It is a presentation choice; the finding is still the core's verdict. @@ -77,10 +81,20 @@ def _collect(src: str) -> tuple[list[Diagnostic], object | None]: return check_module(mod), mod -def cmd_check(path: str) -> int: +def cmd_check(path: str, fmt: str = "human", severity: str = "error") -> int: src = _read(path) diags, _ = _collect(src) errors = [d for d in diags if d.severity == Severity.ERROR] + if fmt == "sarif": + # SARIF 2.1.0 log for GitHub code scanning — carries each diagnostic's + # structured evidence slice as relatedLocations / codeFlows. The exit code + # still reflects the verdict, so `check --format sarif` gates CI the same + # way the human surface does. + import json + + from .diag_sarif import build_sarif + print(json.dumps(build_sarif(diags, path, severity), indent=2)) + return 1 if errors else 0 for d in diags: print(d.render_pretty(path, src)) if not diags: @@ -374,7 +388,7 @@ def main(argv: list[str]) -> int: # 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. opts = {"--format": "human", "--severity": "error", "--verbosity": "normal"} - seen_value_flags = False + seen: set[str] = set() positional: list[str] = [] rest = argv[1:] i = 0 @@ -387,11 +401,13 @@ def main(argv: list[str]) -> int: print(f"{flag} requires a value", file=sys.stderr) return 2 opts[flag], i = rest[i + 1], i + 2 - seen_value_flags = matched = True + seen.add(flag) + matched = True break if a.startswith(flag + "="): opts[flag], i = a.split("=", 1)[1], i + 1 - seen_value_flags = matched = True + seen.add(flag) + matched = True break if matched: continue @@ -416,18 +432,29 @@ def main(argv: list[str]) -> int: print(f"unknown --verbosity {verbosity!r} (choose: " f"{', '.join(sorted(_VERBOSITY))})", file=sys.stderr) return 2 - # `--format`/`--severity`/`--verbosity` are ownir-only — reject them on other - # commands by *presence*, not just non-default value (so `check x --format - # human` is a clear error, not a silent no-op). - if cmd != "ownir" and seen_value_flags: + # Value-flag scope, rejected by *presence* (so a redundant `--format human` is a + # clear error, not a silent no-op): `ownir` takes all three; `check` takes only + # `--format`, and only human|sarif (github/msbuild are per-finding renderers that + # need an OwnIR Finding, not a Diagnostic); every other command takes none. + if cmd == "check": + extra = seen - {"--format"} + if extra: + print(f"{'/'.join(sorted(extra))} only apply to `ownir`", file=sys.stderr) + return 2 + if fmt not in {"human", "sarif"}: + print(f"check --format must be 'human' or 'sarif' (got {fmt!r})", + file=sys.stderr) + return 2 + elif cmd != "ownir" and seen: print("--format/--severity/--verbosity only apply to `ownir`", file=sys.stderr) return 2 path = positional[0] if cmd == "ownir": return cmd_ownir(path, fmt, severity, verbosity) - return {"check": cmd_check, "emit": cmd_emit, "cfg": cmd_cfg, - "report": cmd_report}[cmd](path) + if cmd == "check": + return cmd_check(path, fmt, severity) + return {"emit": cmd_emit, "cfg": cmd_cfg, "report": cmd_report}[cmd](path) if __name__ == "__main__": diff --git a/ownlang/diag_sarif.py b/ownlang/diag_sarif.py new file mode 100644 index 00000000..ffdc44f1 --- /dev/null +++ b/ownlang/diag_sarif.py @@ -0,0 +1,96 @@ +"""Flow diagnostics (``analysis.Diagnostic``) -> SARIF 2.1.0. + +The C# extractor path already emits SARIF via ``ownir.build_sarif`` over +``ownir.Finding``. The ``.own`` flow-diagnostic path (parser -> analysis) had no +SARIF surface, so the structured ``Diagnostic.evidence`` slice (a stack buffer's +acquire->escape at OWN015/OWN016, move->use at OWN005, a leak's acquire site at +OWN001) reached only the human ``check`` render -- never GitHub code scanning. + +This is the missing consumer. It maps each ``Diagnostic`` to a SARIF ``result`` +and projects its evidence through the SAME ``ownlang.evidence`` builders the OwnIR +path uses (``relatedLocations`` for the unordered anchors, ``codeFlows`` for the +ordered slice), so both paths speak one SARIF vocabulary. The log shape mirrors +``ownir.build_sarif``: one ``run`` whose ``tool.driver`` is Own.NET with a +``rules`` catalogue of the OWN codes present, and one ``result`` per diagnostic. +""" + +from __future__ import annotations + +from typing import Any + +from .diagnostics import TITLES, Diagnostic, Evidence, Severity +from .evidence import code_flow, related_locations + +# Mirrors the constants in ``ownir.build_sarif`` -- both paths emit the same SARIF +# 2.1.0 shape from one tool. Kept as literals here (rather than imported from +# ``ownir``) so a ``check`` never has to load the heavier OwnIR module. +_SARIF_SCHEMA = ( + "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/" + "Schemata/sarif-schema-2.1.0.json" +) +_SARIF_INFO_URI = "https://github.com/PhysShell/Own.NET" + + +def _steps(evidence: tuple[Evidence, ...], filename: str) -> list[tuple[str, int, str]]: + """Resolve each evidence step to a concrete ``(file, line, label)`` the SARIF + builders can point at. ``Evidence.file is None`` means "same file as the + diagnostic's anchor", so it resolves to ``filename`` -- an empty URI would make + the whole log unprocessable for GitHub code scanning.""" + return [(e.file or filename, e.line, e.label) for e in evidence] + + +def _result(d: Diagnostic, filename: str, severity: str) -> dict[str, Any]: + """One SARIF ``result`` for a diagnostic: the OWN code is the ``ruleId``, the + ``.own`` location is a ``physicalLocation``, and the evidence slice rides along + as ``relatedLocations`` + ``codeFlows``. ``severity`` is a presentation choice + (it only sets the result ``level``); a diagnostic that is intrinsically a + warning stays a warning regardless.""" + phys: dict[str, Any] = {"artifactLocation": {"uri": filename.replace("\\", "/")}} + if d.line >= 1: # SARIF region.startLine is 1-based; omit for a file-level finding + phys["region"] = {"startLine": d.line} + level = ("warning" if severity == "warning" or d.severity is Severity.WARNING + else "error") + kind = f" [resource: {d.resource_kind}]" if d.resource_kind else "" + result: dict[str, Any] = { + "ruleId": d.code, + "level": level, + "message": {"text": f"{d.message}{kind}"}, + "locations": [{"physicalLocation": phys}], + } + steps = _steps(d.evidence, filename) + related = related_locations(steps) + if related: + result["relatedLocations"] = related + flows = code_flow(steps) + if flows: + result["codeFlows"] = flows + return result + + +def build_sarif(diags: list[Diagnostic], filename: str, + severity: str = "error") -> dict[str, Any]: + """Render flow diagnostics as a single SARIF 2.1.0 log: one ``run`` whose + ``tool.driver`` is Own.NET (with a ``rules`` catalogue of the OWN codes present + and their titles) and whose ``results`` carry each diagnostic's code, location, + message and evidence slice. ``severity`` only sets each result's ``level``.""" + codes = sorted({d.code for d in diags}) + rules = [ + {"id": code, "shortDescription": {"text": TITLES.get(code, code)}} + for code in codes + ] + return { + "$schema": _SARIF_SCHEMA, + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "Own.NET", + "informationUri": _SARIF_INFO_URI, + "rules": rules, + }, + }, + "results": [_result(d, filename, severity) for d in diags], + }, + ], + } diff --git a/tests/run_tests.py b/tests/run_tests.py index b422e952..f10874f1 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1128,6 +1128,12 @@ def run() -> int: import test_evidence_coverage evid_rc = test_evidence_coverage.run() + # Flow-diagnostic SARIF (execution-surfaces ADR §3.1): `check --format sarif` + # projects each Diagnostic's evidence slice into SARIF relatedLocations / + # codeFlows via the shared ownlang.evidence builders, for GitHub code scanning. + import test_diag_sarif + dsarif_rc = test_diag_sarif.run() + # Reactive-effect stability (P-020): the EFF001 effect-storm analysis — the # identity lattice, reference propagation, cycle safety, and the OwnIR bridge # mapping the optional `effects` block to an EFF001 finding (a new core @@ -1140,7 +1146,7 @@ def run() -> int: 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 - or explain_rc or effects_rc or evid_rc) else 0 + or explain_rc or effects_rc or evid_rc or dsarif_rc) else 0 if __name__ == "__main__": diff --git a/tests/test_diag_sarif.py b/tests/test_diag_sarif.py new file mode 100644 index 00000000..5a1290f0 --- /dev/null +++ b/tests/test_diag_sarif.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +""" +Flow-diagnostic SARIF projection (`ownlang.diag_sarif`). + +The C# extractor path emits SARIF via `ownir.build_sarif`; this pins the parallel +`.own` flow-diagnostic path added so `check --format sarif` reaches GitHub code +scanning. It asserts the log shape (schema / driver / rules), that a diagnostic's +structured evidence surfaces as BOTH `relatedLocations` and an ordered `codeFlows` +slice, that an evidence-free diagnostic carries neither, the "no empty +artifactLocation.uri" invariant a code-scanning consumer requires, and the +severity->level mapping. + +Run: python tests/test_diag_sarif.py + python tests/run_tests.py (as part of the suite) +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.analysis import analyze +from ownlang.cfg import build_cfg, collect_policies, collect_signatures +from ownlang.diag_sarif import build_sarif +from ownlang.diagnostics import Diagnostic +from ownlang.parser import parse + + +def _diags(src: str) -> list[Diagnostic]: + mod = parse(src) + rnames = {r.name for r in mod.resources} + sigs = collect_signatures(mod) + pols = collect_policies(mod) + out: list[Diagnostic] = [] + for fn in mod.functions: + cfg, d1 = build_cfg(fn, rnames, sigs, pols) + out += d1 + analyze(cfg) + return out + + +def _result_for(sarif: dict, code: str) -> dict: + for r in sarif["runs"][0]["results"]: + if r["ruleId"] == code: + return r + raise AssertionError(f"no SARIF result for {code}") + + +def _all_uris(node: object) -> list[str]: + """Every artifactLocation.uri anywhere in the log (for the empty-uri check).""" + out: list[str] = [] + if isinstance(node, dict): + for k, v in node.items(): + if k == "artifactLocation" and isinstance(v, dict): + out.append(v.get("uri", "")) + else: + out.extend(_all_uris(v)) + elif isinstance(node, list): + for v in node: + out.extend(_all_uris(v)) + return out + + +_ESCAPE = ( + "module M\n" # 1 + "fn f() -> Buffer {\n" # 2 + " let b = Buffer.stack(64);\n" # 3 <- allocated + " return b;\n" # 4 <- escapes + "}\n" # 5 +) + +_LEAK = ( + "module M\n" # 1 + "resource Conn { acquire open release close }\n" # 2 + "fn f() {\n" # 3 + " let c = acquire Conn(1);\n" # 4 <- acquired + " use c;\n" # 5 + "}\n" # 6 +) + + +def run() -> int: + fails: list[str] = [] + checks = 0 + + def expect(cond: bool, msg: str) -> None: + nonlocal checks + checks += 1 + if not cond: + fails.append(msg) + + esc = build_sarif(_diags(_ESCAPE), "esc.own") + + # -- log shape ---------------------------------------------------------- + expect(esc["$schema"].endswith("sarif-schema-2.1.0.json") and esc["version"] + == "2.1.0", "SARIF log must declare the 2.1.0 schema/version") + driver = esc["runs"][0]["tool"]["driver"] + expect(driver["name"] == "Own.NET", "tool driver must be Own.NET") + expect([r["id"] for r in driver["rules"]] == ["OWN015"], + f"rules catalogue must list the codes present: {driver['rules']}") + + # -- OWN015: evidence -> relatedLocations + ordered codeFlows ----------- + r = _result_for(esc, "OWN015") + expect(r["level"] == "error" and r["locations"][0]["physicalLocation"]["region"] + ["startLine"] == 4, "OWN015 result anchors at the return line") + related = [(x["physicalLocation"]["region"]["startLine"], x["message"]["text"]) + for x in r.get("relatedLocations", [])] + expect(related == [(3, "'b' allocated here"), + (4, "escapes the function by return here")], + f"OWN015 relatedLocations wrong: {related}") + flow = [(loc["location"]["physicalLocation"]["region"]["startLine"], + loc["location"]["message"]["text"]) + for loc in r["codeFlows"][0]["threadFlows"][0]["locations"]] + expect(flow == related, f"OWN015 codeFlows must mirror the ordered slice: {flow}") + + # -- OWN001 leak: acquire site rides along too -------------------------- + leak = build_sarif(_diags(_LEAK), "leak.own") + r = _result_for(leak, "OWN001") + flow = [(loc["location"]["physicalLocation"]["region"]["startLine"], + loc["location"]["message"]["text"]) + for loc in r["codeFlows"][0]["threadFlows"][0]["locations"]] + expect(flow == [(4, "'c' acquired here")], + f"OWN001 codeFlows must carry the acquire site: {flow}") + + # -- an evidence-free diagnostic carries neither key -------------------- + dbl = build_sarif(_diags( + "module M\nresource Conn { acquire open release close }\n" + "fn f(){ let c = acquire Conn(1); release c; release c; }\n"), "d.own") + r = _result_for(dbl, "OWN003") + expect("codeFlows" not in r and "relatedLocations" not in r, + "an evidence-free diagnostic must not carry codeFlows/relatedLocations") + + # -- no empty artifactLocation.uri anywhere (code-scanning invariant) --- + uris = _all_uris(esc) + _all_uris(leak) + expect(uris and all(uris), f"every artifactLocation.uri must be non-empty: {uris}") + + # -- severity override sets the result level ---------------------------- + warn = build_sarif(_diags(_ESCAPE), "esc.own", severity="warning") + expect(_result_for(warn, "OWN015")["level"] == "warning", + "severity='warning' must set the result level to warning") + + for f in fails: + print(f"DIAG-SARIF FAIL: {f}") + print(f"diag_sarif: {checks - len(fails)}/{checks} SARIF projection checks pass") + return 1 if fails else 0 + + +if __name__ == "__main__": + raise SystemExit(run())