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
19 changes: 19 additions & 0 deletions docs/notes/sarif-export.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
49 changes: 38 additions & 11 deletions ownlang/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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__":
Expand Down
96 changes: 96 additions & 0 deletions ownlang/diag_sarif.py
Original file line number Diff line number Diff line change
@@ -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],
},
],
}
8 changes: 7 additions & 1 deletion tests/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__":
Expand Down
150 changes: 150 additions & 0 deletions tests/test_diag_sarif.py
Original file line number Diff line number Diff line change
@@ -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())
Loading