Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
"""Render findings (+ metadata caveats) as text or JSON (SPEC §3.1).
A report ALWAYS prints a metadata header — passive-pin ratio, No-ERC suppressed
count, unnamed-net count and frac-coord presence — so that a vacuous "no findings"
pass is never mistaken for a clean board.
"""
from __future__ import annotations
import enum
import fnmatch
import hashlib
import json
from dataclasses import dataclass, field, replace
class Severity(enum.Enum):
"""Finding severity, ordered least -> most serious."""
INFO = "info"
NOTE = "note"
WARNING = "warning"
ERROR = "error"
CRITICAL = "critical"
# rank for sorting (higher = more serious)
_SEV_RANK: dict[Severity, int] = {
Severity.INFO: 0,
Severity.NOTE: 1,
Severity.WARNING: 2,
Severity.ERROR: 3,
Severity.CRITICAL: 4,
}
# Review evidence-envelope vocabularies (schemas/findings.schema.json
# mirrors these).
CONFIDENCE_LEVELS: tuple[str, ...] = (
"deterministic", "heuristic", "datasheet_backed", "llm_reviewed",
)
FINDING_STATUSES: tuple[str, ...] = (
"reported", "waived", "accepted_risk", "quarantined",
"insufficient_evidence",
)
@dataclass
class Finding:
"""A single check result.
``pos`` is an optional ``(x_mil, y_mil)`` world coordinate the finding
points at (top-left origin, +Y down — the canonical model frame). ``anchors``
is an optional list of ``{kind, id, pos?}`` dicts naming the concrete
entities involved (kind ∈ ``component|pin|net|label``); build them with
:func:`anchor`. Both feed the JSON/SARIF exports and the web UI's markers;
findings without positions render exactly as before.
The remaining fields are the OPTIONAL review evidence envelope (all default
to unset and are serialized only when present, so pre-review findings keep
their historical JSON shape byte-for-byte):
* ``detector`` — the detector module that produced the finding.
* ``confidence`` — one of :data:`CONFIDENCE_LEVELS`.
* ``evidence`` — dict carrying the *why*: ``source`` (datasheet / topology /
heuristic_rule / symbol_footprint / bom / geometry / api_lookup / calc),
a ``calc`` envelope, ``datasheet`` ``{sha256, page, quote?}`` and
``assumptions``.
* ``rule_version`` — bumped (major) only when the rule's SEMANTICS change.
* ``fingerprint`` — stable identity over (code, rule_version-major, anchors);
deliberately message-free so rewording never churns CI alerts. Compute
with :func:`compute_fingerprint`.
* ``remediation`` — suggested fix, human-readable.
* ``fix_params`` — structured fix metadata for ``review propose``.
* ``status`` — one of :data:`FINDING_STATUSES`.
"""
code: str
severity: Severity
message: str
refs: list = field(default_factory=list)
pos: tuple | None = None
anchors: list = field(default_factory=list)
detector: str | None = None
confidence: str | None = None
evidence: dict | None = None
rule_version: str | None = None
fingerprint: str | None = None
remediation: str | None = None
fix_params: dict | None = None
status: str | None = None
def compute_fingerprint(code: str, rule_version: str | None,
anchors: list) -> str:
"""Stable finding identity: sha256(code | rule-major | sorted anchors)[:32].
Message text is deliberately EXCLUDED — rewording a finding must never
churn alert identity, so identity rests on the rule code, its semantic
(major) version, and the anchored entities alone.
"""
major = str(rule_version or "1").split(".", 1)[0]
ids = sorted(f"{a.get('kind', '')}:{a.get('id', '')}" for a in anchors or [])
basis = "|".join([code, major, *ids])
return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:32]
def anchor(kind: str, id: object, pos: tuple | None = None) -> dict:
"""Build one ``anchors`` entry: ``{kind, id[, pos]}`` (see :class:`Finding`)."""
a: dict = {"kind": kind, "id": str(id)}
if pos is not None:
a["pos"] = (float(pos[0]), float(pos[1]))
return a
def _match_waiver(f: Finding, waivers: list[dict]) -> dict | None:
"""First config ``[[waiver]]`` whose ``code`` (fnmatch) and, if given, ``refs``
(any fnmatch pattern vs any of ``f.refs``) match ``f``; else ``None``."""
for w in waivers or []:
code = w.get("code")
if code and not fnmatch.fnmatchcase(f.code, str(code)):
continue
pats = w.get("refs")
if pats:
pat_list = [pats] if isinstance(pats, str) else list(pats)
refs = [str(r) for r in (f.refs or [])]
if not any(fnmatch.fnmatchcase(r, str(p)) for r in refs for p in pat_list):
continue
return w
return None
# ``[[waiver]].severity`` token -> the Severity a matched finding is demoted to
# (``off`` drops the finding entirely and has no entry here).
_WAIVE_TARGET: dict[str, Severity] = {
"note": Severity.NOTE,
"info": Severity.INFO,
}
def apply_waivers(
findings: list[Finding], waivers: list[dict],
detail_out: list[dict] | None = None,
) -> tuple[list[Finding], int, int]:
"""Apply config ``[[waiver]]`` entries uniformly to every finding.
Returns ``(kept, waived, demoted)`` where ``waived`` counts findings a waiver
touched (dropped or demoted) and ``demoted`` counts the subset that were
downgraded (``severity = note|info``) rather than removed (``severity =
off``). A finding's ``code``/``message``/``refs`` are never mutated, so SARIF
``partialFingerprints`` stay stable across a waiver's application.
``detail_out``, when a list is supplied, receives one record per waived
finding (code, refs, action, reason) so reports can SHOW what a waiver
silenced — "config-waived: 1" alone reads as clean when it isn't.
"""
if not waivers:
return list(findings), 0, 0
kept: list[Finding] = []
waived = demoted = 0
for f in findings:
w = _match_waiver(f, waivers)
if w is None:
kept.append(f)
continue
target = _WAIVE_TARGET.get(str(w.get("severity", "off")).lower())
waived += 1
if detail_out is not None:
detail_out.append({
"code": f.code,
"refs": list(f.refs or []),
"action": ("dropped" if target is None
else f"demoted to {target.value}"),
"reason": str(w.get("reason") or ""),
})
if target is None: # severity: off -> drop
continue
demoted += 1
kept.append(replace(f, severity=target))
return kept, waived, demoted
# Metadata keys always surfaced in the header (with friendly labels + defaults).
_META_FIELDS: list[tuple[str, str, str]] = [
("passive_pin_ratio", "passive-pin ratio", "n/a"),
("no_erc_suppressed", "No-ERC suppressed", "0"),
("unnamed_net_count", "unnamed nets", "0"),
("frac_present", "frac coords present", "false"),
("config_waived", "config-waived", "0 (0 demoted)"),
]
def _fmt_pos(pos: tuple) -> str:
"""`(x,y)` with integer coordinates rendered without a trailing `.0`."""
def r(v: float) -> str:
iv = round(v)
return str(int(iv)) if abs(v - iv) < 0.01 else f"{v:.1f}"
return f"({r(pos[0])},{r(pos[1])})"
def _meta_dict(meta: dict | None) -> dict:
meta = meta or {}
out = {}
for key, _label, default in _META_FIELDS:
out[key] = meta.get(key, default)
# preserve any extra metadata the caller passed
for k, v in meta.items():
out.setdefault(k, v)
return out
def _render_text(findings: list[Finding], meta: dict) -> str:
lines: list[str] = []
lines.append("# metadata")
for key, label, _default in _META_FIELDS:
lines.append(f" {label}: {meta[key]}")
lines.append(f"# findings ({len(findings)})")
if not findings:
lines.append(" (none)")
ordered = sorted(findings, key=lambda f: -_SEV_RANK.get(f.severity, 0))
for f in ordered:
sev = f.severity.value.upper()
ref = f" {list(f.refs)}" if f.refs else ""
at = f" @ {_fmt_pos(f.pos)}" if f.pos is not None else ""
lines.append(f" {sev} [{f.code}] {f.message}{ref}{at}")
return "\n".join(lines)
def _render_json(findings: list[Finding], meta: dict) -> str:
from .model import SCHEMA_VERSION # local import avoids any import cycle
payload = {
"schema_version": SCHEMA_VERSION,
"metadata": meta,
"findings": [_finding_json(f) for f in findings],
}
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=False)
def _finding_json(f: Finding) -> dict:
# Optional keys are emitted only when present so findings without them
# keep their historical shape (json turns tuples into arrays).
d: dict = {
"code": f.code,
"severity": f.severity.value,
"message": f.message,
"refs": list(f.refs),
}
if f.pos is not None:
d["pos"] = list(f.pos)
if f.anchors:
d["anchors"] = list(f.anchors)
for key in ("detector", "confidence", "rule_version", "fingerprint",
"remediation", "status"):
v = getattr(f, key)
if v is not None:
d[key] = v
if f.evidence:
d["evidence"] = dict(f.evidence)
if f.fix_params:
d["fix_params"] = dict(f.fix_params)
return d
def finding_from_json(d: dict) -> Finding:
"""Rebuild a :class:`Finding` from its :func:`_finding_json` dict.
Round-trip partner for the findings JSON export (``review report`` re-reads
a findings file to re-render it in another format). Unknown keys are
ignored (consumers must tolerate additive schema growth).
"""
return Finding(
code=str(d.get("code", "")),
severity=Severity(d.get("severity", "info")),
message=str(d.get("message", "")),
refs=list(d.get("refs", [])),
pos=tuple(d["pos"]) if d.get("pos") is not None else None,
anchors=list(d.get("anchors", [])),
detector=d.get("detector"),
confidence=d.get("confidence"),
evidence=d.get("evidence"),
rule_version=d.get("rule_version"),
fingerprint=d.get("fingerprint"),
remediation=d.get("remediation"),
fix_params=d.get("fix_params"),
status=d.get("status"),
)
# SARIF severity levels (GitHub code scanning): error / warning / note.
_SARIF_LEVEL = {
Severity.CRITICAL: "error",
Severity.ERROR: "error",
Severity.WARNING: "warning",
Severity.NOTE: "note",
Severity.INFO: "note",
}
def _render_sarif(findings: list[Finding], meta: dict, source: str | None) -> str:
"""SARIF 2.1.0 for GitHub code scanning / other SARIF consumers.
Schematic findings have no line numbers, so locations carry only the
artifact URI; ``partialFingerprints`` keeps alert identity stable across
runs for the same (code, message, refs) triple.
"""
import hashlib
from . import __version__
from .model import SCHEMA_VERSION
rules: dict[str, dict] = {}
results = []
for f in sorted(findings, key=lambda f: (-_SEV_RANK.get(f.severity, 0), f.code)):
rules.setdefault(f.code, {
"id": f.code,
"shortDescription": {"text": f.code.replace("_", " ").title()},
})
fp = hashlib.sha256(
"|".join([f.code, f.message, *map(str, f.refs)]).encode("utf-8")
).hexdigest()[:32]
text = f.message
if f.refs:
text += " (refs: " + ", ".join(map(str, f.refs)) + ")"
# v1 (message-based) stays for alert continuity; v2 is the wording-
# immune review fingerprint and wins in consumers that know it.
fps = {"akcliFinding/v1": fp}
if f.fingerprint:
fps["akcliFinding/v2"] = f.fingerprint
result: dict = {
"ruleId": f.code,
"level": _SARIF_LEVEL.get(f.severity, "note"),
"message": {"text": text},
"partialFingerprints": fps,
}
if source:
result["locations"] = [{
"physicalLocation": {
"artifactLocation": {"uri": str(source).replace("\\", "/")}
}
}]
# Position data rides in logicalLocations (the named entities) and a
# tool-specific properties.akcli bag; it is deliberately kept OUT of the
# partialFingerprints above so alert identity never churns when a check
# starts (or stops) emitting coordinates.
if f.anchors:
result.setdefault("locations", [{}])
result["locations"][0]["logicalLocations"] = [
{"name": a.get("id", ""), "kind": a.get("kind", "")}
for a in f.anchors
]
if f.pos is not None or f.anchors or f.confidence or f.detector:
akcli: dict = {}
if f.pos is not None:
akcli["pos"] = list(f.pos)
if f.anchors:
akcli["anchors"] = list(f.anchors)
if f.confidence:
akcli["confidence"] = f.confidence
if f.detector:
akcli["detector"] = f.detector
result["properties"] = {"akcli": akcli}
results.append(result)
payload = {
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {"driver": {
"name": "akcli",
"informationUri": "https://github.com/tipoLi5890/akcli",
"version": __version__,
"rules": list(rules.values()),
}},
"properties": {"schema_version": SCHEMA_VERSION, "metadata": meta},
"results": results,
}],
}
return json.dumps(payload, ensure_ascii=False, indent=2)
def _xml_escape(s: str) -> str:
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">")
.replace('"', """))
def _render_junit(findings: list[Finding], meta: dict, source: str | None) -> str:
"""JUnit XML for CI test reporters.
Each WARNING+ finding is a failed testcase; NOTE/INFO become passed
testcases carrying their text in ``system-out``; a clean run emits one
passed "no findings" case so the suite is never empty.
"""
cases: list[str] = []
failures = 0
ordered = sorted(findings, key=lambda f: (-_SEV_RANK.get(f.severity, 0), f.code))
for f in ordered:
name = _xml_escape(f.message[:200])
cls = f"akcli.{_xml_escape(f.code)}"
body = _xml_escape(f.message)
if f.refs:
body += "\nrefs: " + _xml_escape(", ".join(map(str, f.refs)))
if _SEV_RANK.get(f.severity, 0) >= _SEV_RANK[Severity.WARNING]:
failures += 1
cases.append(
f' <testcase classname="{cls}" name="{name}">\n'
f' <failure message="{_xml_escape(f.severity.value)}">{body}</failure>\n'
f" </testcase>"
)
else:
cases.append(
f' <testcase classname="{cls}" name="{name}">\n'
f" <system-out>{body}</system-out>\n"
f" </testcase>"
)
if not cases:
cases.append(' <testcase classname="akcli" name="no findings"/>')
suite_name = _xml_escape(source or "akcli-check")
total = max(len(findings), 1)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<testsuites tests="{total}" failures="{failures}">\n'
f' <testsuite name="{suite_name}" tests="{total}" failures="{failures}">\n'
+ "\n".join(cases)
+ "\n </testsuite>\n</testsuites>\n"
)
def _render_markdown(findings: list[Finding], meta: dict,
source: str | None) -> str:
"""GitHub-flavoured Markdown report: trust summary + one table row each.
The trust summary counts findings by confidence: deterministic findings
carry weight, heuristics are flagged for manual review — a reader should
never mistake one for the other.
"""
lines: list[str] = [f"# akcli findings — {source or 'report'}", ""]
by_conf: dict[str, int] = {}
for f in findings:
by_conf[f.confidence or "unspecified"] = (
by_conf.get(f.confidence or "unspecified", 0) + 1)
lines.append(f"**{len(findings)} finding(s)**"
+ (" — " + ", ".join(f"{k}: {v}" for k, v in
sorted(by_conf.items()))
if findings else ""))
lines.append("")
if findings:
lines.append("| severity | code | confidence | message | refs |")
lines.append("|---|---|---|---|---|")
ordered = sorted(findings,
key=lambda f: (-_SEV_RANK.get(f.severity, 0), f.code))
for f in ordered:
msg = f.message.replace("|", "\\|")
refs = ", ".join(map(str, f.refs)).replace("|", "\\|")
lines.append(f"| {f.severity.value.upper()} | `{f.code}` | "
f"{f.confidence or '-'} | {msg} | {refs} |")
remediations = [(f.code, f.remediation) for f in ordered if f.remediation]
if remediations:
lines += ["", "## Remediation", ""]
for code, rem in remediations:
lines.append(f"- `{code}` — {rem}")
else:
lines.append("(none)")
lines += ["", "## Metadata", ""]
for key, label, _default in _META_FIELDS:
lines.append(f"- {label}: {meta[key]}")
return "\n".join(lines) + "\n"
def render(
findings: list[Finding],
fmt: str = "text",
meta: dict | None = None,
source: str | None = None,
) -> str:
"""Render findings as ``text``/``json`` (metadata header always present),
``sarif`` (GitHub code scanning), ``junit`` (CI test reporters) or
``markdown`` (human review summary with a trust rollup)."""
m = _meta_dict(meta)
if fmt == "json":
return _render_json(findings, m)
if fmt == "sarif":
return _render_sarif(findings, m, source)
if fmt == "junit":
return _render_junit(findings, m, source)
if fmt == "markdown":
return _render_markdown(findings, m, source)
return _render_text(findings, m)
2126c3284This file is pinned to the selected release. Download the original to open it in your design tool.