reqthread 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
reqthread/gates.py ADDED
@@ -0,0 +1,138 @@
1
+ """Gate rules. Each gap carries a stable id so a build can be ratcheted against a baseline."""
2
+ import datetime as dt
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Gap:
9
+ rule: int
10
+ subject: str # entity id the gap is about
11
+ qualifier: str # distinguishes several gaps of one rule on one subject
12
+ message: str
13
+
14
+ @property
15
+ def id(self):
16
+ return f"rule{self.rule}:{self.subject}:{self.qualifier}"
17
+
18
+ def as_dict(self):
19
+ return {"id": self.id, "rule": self.rule, "subject": self.subject, "message": self.message}
20
+
21
+
22
+ RULES = {
23
+ 1: "Every software safety requirement has a verifying test at each declared test level.",
24
+ 2: "Every software safety requirement derives from a system-level requirement.",
25
+ 3: "Every code unit traces to a requirement or design element, or is marked infrastructure.",
26
+ 4: "Every test verifying a safety requirement passed at baseline and failed nowhere.",
27
+ 7: "Every static-analysis finding is fixed or covered by an unexpired deviation.",
28
+ 11: "Every hazard is mitigated by at least one requirement, directly or through one of its causes.",
29
+ 12: "Every cause is mitigated by a requirement, or its residual risk is formally accepted.",
30
+ 13: "Every requirement that mitigates a hazard or cause is a safety requirement with an integrity level.",
31
+ }
32
+
33
+
34
+ def evaluate(bundle):
35
+ by_id = {e["id"]: e for e in bundle["entities"]}
36
+ out_links, in_links = defaultdict(list), defaultdict(list)
37
+ for l in bundle["links"]:
38
+ out_links[l["from"]].append(l)
39
+ in_links[l["to"]].append(l)
40
+
41
+ def ents(kind):
42
+ return [e for e in bundle["entities"] if e["kind"] == kind]
43
+
44
+ gaps = []
45
+ ssrs = [r for r in ents("requirement") if r["level"] in ("software", "derived") and r["status"] != "retired"]
46
+ ssr_ids = {r["id"] for r in ssrs}
47
+
48
+ # Rule 1
49
+ for r in ssrs:
50
+ if "test" not in r.get("verification", []):
51
+ continue
52
+ tests = [by_id[l["from"]] for l in in_links[r["id"]] if l["type"] == "verifies"]
53
+ for lvl in r.get("test_levels", []):
54
+ if not any(t["level"] == lvl for t in tests):
55
+ gaps.append(Gap(1, r["id"], lvl, f"{r['key']} has no verifying test at level '{lvl}'"))
56
+
57
+ # Rule 2
58
+ for r in ssrs:
59
+ parents = [by_id[l["to"]] for l in out_links[r["id"]] if l["type"] == "derives_from"]
60
+ ok = any(p["level"] == "system" for p in parents) if r["level"] == "software" else bool(parents)
61
+ if not ok:
62
+ want = "a system-level requirement" if r["level"] == "software" else "any requirement"
63
+ gaps.append(Gap(2, r["id"], "source", f"{r['key']} does not derive from {want}"))
64
+
65
+ # Rule 3 — a function is covered by its own trace, or by a file-level unit for the same path.
66
+ def traced(unit):
67
+ return unit["infrastructure"] is not None or any(
68
+ l["type"] in ("satisfies", "implements") for l in out_links[unit["id"]])
69
+
70
+ file_units = {u["path"]: u for u in ents("code_unit") if u["symbol"] is None}
71
+ for u in ents("code_unit"):
72
+ if traced(u):
73
+ continue
74
+ parent = file_units.get(u["path"]) if u["symbol"] is not None else None
75
+ if parent is not None and traced(parent):
76
+ continue
77
+ gaps.append(Gap(3, u["id"], "trace", f"{u['id'].removeprefix('code:')} traces to no requirement or design element"))
78
+
79
+ # Rule 4
80
+ results_by_test = defaultdict(list)
81
+ for res in ents("test_result"):
82
+ results_by_test[res["test"]].append(res)
83
+ verifying_tests = {l["from"] for l in bundle["links"] if l["type"] == "verifies" and l["to"] in ssr_ids}
84
+ for tid in sorted(verifying_tests):
85
+ verified = sorted({l["to"] for l in out_links[tid] if l["type"] == "verifies" and l["to"] in ssr_ids})
86
+ keys = ", ".join(by_id[v]["key"] for v in verified)
87
+ name = tid.removeprefix("test:")
88
+ results = results_by_test.get(tid, [])
89
+ for res in results:
90
+ if res["outcome"] in ("fail", "error"):
91
+ env = res["environment"]
92
+ gaps.append(Gap(4, tid, f"{env}:{res['outcome']}",
93
+ f"{name} {res['outcome']}s on {env.removeprefix('env:')} (verifies {keys})"))
94
+ if not any(r["outcome"] == "pass" for r in results):
95
+ why = "only skipped" if results else "has no result"
96
+ gaps.append(Gap(4, tid, "not-passed", f"{name} {why} at baseline (verifies {keys})"))
97
+
98
+ # Rule 7
99
+ as_of = dt.datetime.fromisoformat(bundle["created_at"].replace("Z", "+00:00")).date()
100
+ for f in ents("finding"):
101
+ where = f"{f['location']['path']}:{f['location'].get('line', '?')}"
102
+ if f["state"] == "open":
103
+ gaps.append(Gap(7, f["id"], "open", f"{f['rule_id']} at {where} is open"))
104
+ elif f["state"] == "deviated":
105
+ devs = [by_id[l["from"]] for l in in_links[f["id"]] if l["type"] == "justifies"]
106
+ live = [d for d in devs if d["expires_at"] is None or dt.date.fromisoformat(d["expires_at"]) >= as_of]
107
+ if not live:
108
+ gaps.append(Gap(7, f["id"], "deviation", f"{f['rule_id']} at {where} has no unexpired deviation"))
109
+
110
+ # Rules 11-13: hazard analysis
111
+ live = lambda e: e["status"] != "retired"
112
+ hazards = [h for h in ents("hazard") if live(h)]
113
+ causes = [c for c in ents("cause") if live(c)]
114
+ mitigators = lambda eid: [by_id[l["from"]] for l in in_links[eid] if l["type"] == "mitigates" and live(by_id[l["from"]])]
115
+ cause_hazards = {c["id"]: [l["to"] for l in out_links[c["id"]] if l["type"] == "leads_to"] for c in causes}
116
+ for h in hazards:
117
+ direct = mitigators(h["id"])
118
+ via = [c for c in causes if h["id"] in cause_hazards[c["id"]] and (mitigators(c["id"]) or c["accepted"])]
119
+ if not direct and not via:
120
+ gaps.append(Gap(11, h["id"], "unmitigated", f"{h['key']} ({h['title']}) has no mitigating requirement"))
121
+ for c in causes:
122
+ if not mitigators(c["id"]) and not c["accepted"]:
123
+ gaps.append(Gap(12, c["id"], "unmitigated", f"{c['key']} ({c['title']}) has no mitigating requirement and no accepted-risk record"))
124
+ for r in ents("requirement"):
125
+ if live(r) and any(l["type"] == "mitigates" for l in out_links[r["id"]]) and not r.get("integrity_level"):
126
+ gaps.append(Gap(13, r["id"], "integrity", f"{r['key']} mitigates a hazard but has no integrity level"))
127
+
128
+ return sorted(gaps, key=lambda g: (g.rule, g.subject, g.qualifier))
129
+
130
+
131
+ def ratchet(current, baseline):
132
+ """Split current gaps into (new, existing) relative to baseline gaps; also return fixed baseline gaps."""
133
+ base_ids = {g.id for g in baseline}
134
+ cur_ids = {g.id for g in current}
135
+ new = [g for g in current if g.id not in base_ids]
136
+ existing = [g for g in current if g.id in base_ids]
137
+ fixed = [g for g in baseline if g.id not in cur_ids]
138
+ return new, existing, fixed
reqthread/ingest.py ADDED
@@ -0,0 +1,221 @@
1
+ """Parsers for CI outputs: JUnit XML, lcov, Cobertura XML, SARIF."""
2
+ import datetime as dt
3
+ import json
4
+ import pathlib
5
+ import re
6
+ import xml.etree.ElementTree as ET
7
+ from collections import defaultdict
8
+ from dataclasses import dataclass
9
+
10
+ OUTCOME_RANK = {"error": 3, "fail": 2, "pass": 1, "skip": 0}
11
+
12
+
13
+ # ------------------------------------------------------------------ paths
14
+
15
+ def normalize_path(raw, repo_root, known_paths=()):
16
+ """Map a path reported by a tool to a repo-relative POSIX path, or None if it lies outside the repo."""
17
+ raw = raw.removeprefix("file://")
18
+ p = pathlib.PurePosixPath(raw)
19
+ root = pathlib.PurePosixPath(pathlib.Path(repo_root).resolve().as_posix())
20
+ if p.is_absolute():
21
+ try:
22
+ return p.relative_to(root).as_posix()
23
+ except ValueError:
24
+ for k in known_paths: # tool ran in another checkout location
25
+ if raw.endswith("/" + k):
26
+ return k
27
+ return None
28
+ rel = pathlib.PurePosixPath(*[part for part in p.parts if part != "."]).as_posix()
29
+ return None if rel.startswith("..") else rel
30
+
31
+
32
+ # ------------------------------------------------------------------ JUnit
33
+
34
+ @dataclass
35
+ class JunitCase:
36
+ classname: str
37
+ name: str
38
+ outcome: str
39
+ timestamp: str | None
40
+
41
+
42
+ def _iso_utc(ts):
43
+ if not ts:
44
+ return None
45
+ try:
46
+ t = dt.datetime.fromisoformat(ts.replace("Z", "+00:00"))
47
+ except ValueError:
48
+ return None
49
+ if t.tzinfo is None:
50
+ t = t.astimezone() # naive timestamps from pytest are local time
51
+ return t.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
52
+
53
+
54
+ def parse_junit(path):
55
+ root = ET.parse(path).getroot()
56
+ suites = [root] if root.tag == "testsuite" else root.iter("testsuite")
57
+ cases = []
58
+ for suite in suites:
59
+ ts = _iso_utc(suite.get("timestamp"))
60
+ for tc in suite.findall("testcase"):
61
+ if tc.find("error") is not None:
62
+ outcome = "error"
63
+ elif tc.find("failure") is not None:
64
+ outcome = "fail"
65
+ elif tc.find("skipped") is not None:
66
+ outcome = "skip"
67
+ else:
68
+ outcome = "pass"
69
+ cases.append(JunitCase(tc.get("classname", ""), tc.get("name", ""), outcome, ts))
70
+ return cases
71
+
72
+
73
+ def junit_match_keys(test_path, symbol):
74
+ """Keys a JUnit testcase may use for a test: pytest dotted module path, or the bare symbol."""
75
+ keys = {symbol}
76
+ if test_path.endswith(".py"):
77
+ module = test_path[:-3].replace("/", ".")
78
+ keys.add(f"{module}.{symbol}")
79
+ return keys
80
+
81
+
82
+ def match_junit(cases, tests):
83
+ """tests: {test_entity_id: (path, symbol)}. Returns ({test_id: outcome}, unmatched_case_labels)."""
84
+ exact, by_last = defaultdict(set), defaultdict(set)
85
+ for tid, (path, symbol) in tests.items():
86
+ for k in junit_match_keys(path, symbol):
87
+ exact[k].add(tid)
88
+ by_last[symbol.rsplit(".", 1)[-1]].add(tid)
89
+
90
+ def only(candidates):
91
+ # Ambiguity is never resolved by guessing: crediting a result to the wrong test is worse than none.
92
+ return next(iter(candidates)) if len(candidates) == 1 else None
93
+
94
+ outcomes, unmatched = {}, []
95
+ for c in cases:
96
+ name = re.sub(r"\[.*\]$", "", c.name) # pytest parametrisation
97
+ tid = only(exact.get(f"{c.classname}.{name}", set())) if c.classname else None
98
+ tid = tid or only(exact.get(name, set()))
99
+ if tid is None:
100
+ tid = only(by_last.get(re.split(r"\.|::", name)[-1], set()))
101
+ if tid is None:
102
+ unmatched.append(f"{c.classname}.{c.name}" if c.classname else c.name)
103
+ continue
104
+ prev = outcomes.get(tid)
105
+ if prev is None or OUTCOME_RANK[c.outcome] > OUTCOME_RANK[prev[0]]: # error > fail > pass > skip
106
+ outcomes[tid] = (c.outcome, c.timestamp)
107
+ return outcomes, unmatched
108
+
109
+
110
+ # ------------------------------------------------------------------ coverage
111
+
112
+ @dataclass
113
+ class FileCoverage:
114
+ lines: dict # line -> hits
115
+ branches: dict # line -> (covered, total)
116
+
117
+
118
+ def parse_lcov(path, repo_root, known_paths=()):
119
+ files, current, unmapped = {}, None, []
120
+ for raw in pathlib.Path(path).read_text().splitlines():
121
+ if raw.startswith("SF:"):
122
+ rel = normalize_path(raw[3:], repo_root, known_paths)
123
+ if rel is None:
124
+ unmapped.append(raw[3:])
125
+ current = files.setdefault(rel, FileCoverage({}, {})) if rel else None
126
+ elif current is None:
127
+ continue
128
+ elif raw.startswith("DA:"):
129
+ line, hits = raw[3:].split(",")[:2]
130
+ current.lines[int(line)] = current.lines.get(int(line), 0) + int(hits)
131
+ elif raw.startswith("BRDA:"):
132
+ parts = raw[5:].split(",")
133
+ line, taken = parts[0], parts[-1]
134
+ cov, tot = current.branches.get(int(line), (0, 0))
135
+ current.branches[int(line)] = (cov + (taken not in ("-", "0")), tot + 1)
136
+ return files, unmapped
137
+
138
+
139
+ def parse_cobertura(path, repo_root, known_paths=()):
140
+ root = ET.parse(path).getroot()
141
+ sources = [s.text.strip() for s in root.iter("source") if s.text and s.text.strip()] or [""]
142
+ files, unmapped = {}, []
143
+ for cls in root.iter("class"):
144
+ fname = cls.get("filename", "")
145
+ rel = None
146
+ for src in sources:
147
+ candidate = fname if not src or fname.startswith("/") else f"{src.rstrip('/')}/{fname}"
148
+ rel = normalize_path(candidate, repo_root, known_paths)
149
+ if rel:
150
+ break
151
+ if rel is None:
152
+ unmapped.append(fname)
153
+ continue
154
+ fc = files.setdefault(rel, FileCoverage({}, {}))
155
+ for ln in cls.iter("line"):
156
+ n = int(ln.get("number"))
157
+ fc.lines[n] = fc.lines.get(n, 0) + int(ln.get("hits", "0"))
158
+ m = re.search(r"\((\d+)/(\d+)\)", ln.get("condition-coverage", ""))
159
+ if ln.get("branch") == "true" and m:
160
+ fc.branches[n] = (int(m.group(1)), int(m.group(2)))
161
+ return files, unmapped
162
+
163
+
164
+ def unit_metrics(fc, start, end):
165
+ """Statement and branch counts for one code unit's line range, or None if nothing instrumented."""
166
+ lines = [h for ln, h in fc.lines.items() if start <= ln <= end]
167
+ if not lines:
168
+ return None
169
+ metrics = {"statement": {"covered": sum(1 for h in lines if h > 0), "total": len(lines)}}
170
+ br = [v for ln, v in fc.branches.items() if start <= ln <= end]
171
+ if br:
172
+ metrics["branch"] = {"covered": sum(c for c, _ in br), "total": sum(t for _, t in br)}
173
+ return metrics
174
+
175
+
176
+ # ------------------------------------------------------------------ SARIF
177
+
178
+ @dataclass
179
+ class SarifResult:
180
+ tool: str
181
+ tool_version: str | None
182
+ rule_id: str
183
+ message: str
184
+ path: str
185
+ line: int | None
186
+ level: str
187
+ fingerprint: str | None
188
+ suppressed_in_source: bool
189
+
190
+
191
+ def parse_sarif(path, repo_root, known_paths=()):
192
+ doc = json.loads(pathlib.Path(path).read_text())
193
+ out, unmapped = [], []
194
+ for run in doc.get("runs", []):
195
+ driver = run.get("tool", {}).get("driver", {})
196
+ tool = driver.get("name", "unknown")
197
+ version = driver.get("semanticVersion") or driver.get("version")
198
+ rule_levels = {r.get("id"): r.get("defaultConfiguration", {}).get("level")
199
+ for r in driver.get("rules", [])}
200
+ for r in run.get("results", []):
201
+ locs = r.get("locations") or [{}]
202
+ phys = locs[0].get("physicalLocation", {})
203
+ uri = phys.get("artifactLocation", {}).get("uri", "")
204
+ rel = normalize_path(uri, repo_root, known_paths) if uri else None
205
+ if rel is None:
206
+ unmapped.append(uri or "<no location>")
207
+ continue
208
+ rule_id = r.get("ruleId") or "unknown"
209
+ fps = r.get("partialFingerprints") or r.get("fingerprints") or {}
210
+ out.append(SarifResult(
211
+ tool=tool,
212
+ tool_version=version,
213
+ rule_id=rule_id,
214
+ message=(r.get("message") or {}).get("text", ""),
215
+ path=rel,
216
+ line=phys.get("region", {}).get("startLine"),
217
+ level=r.get("level") or rule_levels.get(rule_id) or "warning",
218
+ fingerprint=next(iter(sorted(fps.values())), None) if fps else None,
219
+ suppressed_in_source=bool(r.get("suppressions")),
220
+ ))
221
+ return out, unmapped
reqthread/report.py ADDED
@@ -0,0 +1,224 @@
1
+ """Self-contained static HTML report: gate result, traceability matrix, code units, findings, manifest."""
2
+ from collections import defaultdict
3
+ from html import escape
4
+
5
+ from .gates import RULES
6
+
7
+ CSS = """
8
+ :root{--bg:#fbfaf7;--fg:#1d1d1b;--muted:#6b6862;--line:#e3dfd6;--card:#fff;--ok:#1f7a4a;--bad:#b42318;--warn:#a15c07;--chip:#f1eee6}
9
+ @media (prefers-color-scheme:dark){:root{--bg:#161614;--fg:#ecebe6;--muted:#a3a097;--line:#34332f;--card:#1e1e1b;--ok:#4cc38a;--bad:#ff7b6b;--warn:#f0b35a;--chip:#2a2926}}
10
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
11
+ main{max-width:1200px;margin:0 auto;padding:24px 16px 64px}h1{font-size:22px;margin:0 0 4px}h2{font-size:16px;margin:32px 0 8px}
12
+ .meta{color:var(--muted);font-size:13px}.meta code{font-size:12px}
13
+ .banner{margin:20px 0;padding:14px 16px;border-radius:8px;border:1px solid var(--line);background:var(--card);display:flex;gap:16px;align-items:center;flex-wrap:wrap}
14
+ .badge{font-weight:700;padding:3px 10px;border-radius:999px;color:#fff}.pass{background:var(--ok)}.fail{background:var(--bad)}
15
+ .wrap{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--card)}
16
+ table{border-collapse:collapse;width:100%;min-width:760px;font-size:13px}th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}
17
+ th{font-weight:600;color:var(--muted);white-space:nowrap}tr:last-child td{border-bottom:0}
18
+ code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
19
+ .chip{display:inline-block;background:var(--chip);border-radius:4px;padding:0 6px;margin:1px 2px 1px 0;white-space:nowrap}
20
+ .ok{color:var(--ok)}.bad{color:var(--bad)}.warn{color:var(--warn)}.muted{color:var(--muted)}.new{color:var(--bad);font-weight:600}
21
+ ul.plain{margin:0;padding-left:18px}.nw{white-space:nowrap}
22
+ """
23
+
24
+
25
+ def _e(s):
26
+ return escape(str(s), quote=True)
27
+
28
+
29
+ def _short(eid):
30
+ return eid.split(":", 1)[1]
31
+
32
+
33
+ def _pct(m):
34
+ if not m or not m["total"]:
35
+ return "—"
36
+ v = 100 * m["covered"] / m["total"]
37
+ cls = "ok" if m["covered"] == m["total"] else ("warn" if v >= 80 else "bad")
38
+ return f'<span class="nw"><span class="{cls}">{v:.0f}%</span> <span class="muted">{m["covered"]}/{m["total"]}</span></span>'
39
+
40
+
41
+ def render(bundle, gaps, new_ids=None, fixed=None, warnings=None):
42
+ ents = {e["id"]: e for e in bundle["entities"]}
43
+ by_kind = defaultdict(list)
44
+ for e in bundle["entities"]:
45
+ by_kind[e["kind"]].append(e)
46
+ out_l, in_l = defaultdict(list), defaultdict(list)
47
+ for l in bundle["links"]:
48
+ out_l[l["from"]].append(l)
49
+ in_l[l["to"]].append(l)
50
+ results = defaultdict(list)
51
+ for r in by_kind["test_result"]:
52
+ results[r["test"]].append(r)
53
+ cov = defaultdict(list)
54
+ for c in by_kind["coverage_record"]:
55
+ cov[c["code_unit"]].append(c)
56
+
57
+ ratcheted = new_ids is not None
58
+ failing = [g for g in gaps if g.id in new_ids] if ratcheted else gaps
59
+ p, b = bundle["project"], bundle["baseline"]
60
+ h = []
61
+ h.append(f"<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
62
+ f"<title>Traceability report</title><style>{CSS}</style></head><body><main>")
63
+ h.append(f"<h1>{_e(p['name'])}</h1>")
64
+ h.append(f"<div class='meta'>{_e(p['standard']['id'])} {_e(p['standard']['edition'])} · claim <b>{_e(p['integrity_claim'])}</b>"
65
+ f" · baseline <b>{_e(b['id'])}</b> · commit <code>{_e(b['commit'][:12])}</code>"
66
+ f" · generated {_e(bundle['created_at'])} by {_e(bundle['generator']['name'])} {_e(bundle['generator']['version'])}</div>")
67
+
68
+ status = "fail" if failing else "pass"
69
+ summary = f"{len(gaps)} gap(s)"
70
+ if ratcheted:
71
+ summary += f" — {len(failing)} new, {len(gaps) - len(failing)} carried from baseline, {len(fixed or [])} fixed"
72
+ h.append(f"<div class='banner'><span class='badge {status}'>{status.upper()}</span><span>{_e(summary)}</span></div>")
73
+
74
+ # Gaps
75
+ h.append("<h2>Gaps</h2>")
76
+ if gaps:
77
+ h.append("<div class='wrap'><table><tr><th>Rule</th><th>Gap</th>" + ("<th></th>" if ratcheted else "") + "</tr>")
78
+ for g in gaps:
79
+ tag = ""
80
+ if ratcheted:
81
+ tag = "<td class='new'>new</td>" if g.id in new_ids else "<td class='muted'>baseline</td>"
82
+ h.append(f"<tr><td title='{_e(RULES.get(g.rule, ''))}'>{g.rule}</td><td>{_e(g.message)}</td>{tag}</tr>")
83
+ h.append("</table></div>")
84
+ else:
85
+ h.append("<p class='ok'>No gaps.</p>")
86
+ if fixed:
87
+ h.append("<p class='muted'>Fixed since baseline: " + "; ".join(_e(g.message) for g in fixed) + "</p>")
88
+
89
+ # Requirements matrix
90
+ h.append("<h2>Requirements</h2><div class='wrap'><table><tr><th>Key</th><th>Title</th><th>Level · type</th><th>Integrity</th>"
91
+ "<th>Derives from</th><th>Design · code</th><th>Tests by level</th><th>Status</th></tr>")
92
+ level_rank = {"system": 0, "software": 1, "derived": 2}
93
+ for r in sorted(by_kind["requirement"], key=lambda r: (level_rank[r["level"]], r["key"])):
94
+ parents = [ents[l["to"]]["key"] for l in out_l[r["id"]] if l["type"] == "derives_from"]
95
+ impl = [l["from"] for l in in_l[r["id"]] if l["type"] == "satisfies"]
96
+ impl_html = " ".join(f"<span class='chip'>{_e(ents[i].get('key') or _short(i))}</span>" for i in sorted(impl)) or "<span class='muted'>—</span>"
97
+ tests = [ents[l["from"]] for l in in_l[r["id"]] if l["type"] == "verifies"]
98
+ cells = []
99
+ levels = list(dict.fromkeys(r.get("test_levels", []) + [t["level"] for t in tests]))
100
+ for lvl in levels:
101
+ at = [t for t in tests if t["level"] == lvl]
102
+ if not at:
103
+ cells.append(f"<div><b>{_e(lvl)}</b> <span class='bad'>missing</span></div>")
104
+ continue
105
+ items = []
106
+ for t in at:
107
+ outs = {x["outcome"] for x in results[t["id"]]}
108
+ if outs & {"fail", "error"}:
109
+ mark = "<span class='bad'>✗ failed</span>"
110
+ elif "pass" in outs:
111
+ envs = sorted(_short(x["environment"]) for x in results[t["id"]] if x["outcome"] == "pass")
112
+ mark = f"<span class='ok'>✓</span> <span class='muted'>{_e(', '.join(envs))}</span>"
113
+ elif outs:
114
+ mark = "<span class='warn'>○ skipped</span>"
115
+ else:
116
+ mark = "<span class='warn'>○ not run</span>"
117
+ items.append(f"<li><code>{_e(t['name'].split('::')[-1])}</code> {mark}</li>")
118
+ cells.append(f"<div><b>{_e(lvl)}</b><ul class='plain'>{''.join(items)}</ul></div>")
119
+ tests_html = "".join(cells) or "<span class='muted'>—</span>"
120
+ h.append(f"<tr><td><b>{_e(r['key'])}</b></td><td>{_e(r['title'])}</td><td>{_e(r['level'])} · {_e(r['type'])}</td>"
121
+ f"<td>{_e(r.get('integrity_level') or '—')}</td><td>{_e(', '.join(parents)) or '—'}</td>"
122
+ f"<td>{impl_html}</td><td>{tests_html}</td><td>{_e(r['status'])}</td></tr>")
123
+ h.append("</table></div>")
124
+
125
+ # Code units
126
+ h.append("<h2>Code units</h2><div class='wrap'><table><tr><th>Unit</th><th>Traces to</th><th>Statement</th><th>Branch</th><th>Note</th></tr>")
127
+ def unit_targets(uid):
128
+ return sorted({ents[l["to"]].get("key", _short(l["to"])) for l in out_l[uid] if l["type"] in ("satisfies", "implements")})
129
+
130
+ file_units = {u["path"]: u for u in by_kind["code_unit"] if u["symbol"] is None}
131
+ for u in sorted(by_kind["code_unit"], key=lambda u: u["id"]):
132
+ targets = unit_targets(u["id"])
133
+ trace = " ".join(f"<span class='chip'>{_e(t)}</span>" for t in targets)
134
+ note = ""
135
+ parent = file_units.get(u["path"]) if u["symbol"] is not None else None
136
+ if u["infrastructure"]:
137
+ note = f"<span class='muted'>infrastructure: {_e(u['infrastructure']['reason'])}</span>"
138
+ elif not targets and parent is not None and (parent["infrastructure"] or unit_targets(parent["id"])):
139
+ trace = "<span class='muted'>via file</span>"
140
+ note = f"<span class='muted'>covered by the file-level annotation on {_e(u['path'])}</span>"
141
+ elif not targets:
142
+ trace = "<span class='bad'>untraced</span>"
143
+ stmt = br = "—"
144
+ if cov[u["id"]]:
145
+ c = cov[u["id"]][0]["metrics"]
146
+ stmt, br = _pct(c.get("statement")), _pct(c.get("branch"))
147
+ h.append(f"<tr><td><code>{_e(_short(u['id']))}</code></td><td>{trace or '—'}</td><td>{stmt}</td><td>{br}</td><td>{note}</td></tr>")
148
+ h.append("</table></div>")
149
+
150
+ # Hazards
151
+ if by_kind["hazard"] or by_kind["cause"]:
152
+ h.append("<h2>Hazard analysis</h2><div class='wrap'><table><tr><th>Hazard</th><th>Cause</th><th>Mitigated by</th><th>Verified</th></tr>")
153
+ def req_cell(rid):
154
+ r = ents[rid]
155
+ tests = [ents[l["from"]] for l in in_l[rid] if l["type"] == "verifies"]
156
+ if not tests:
157
+ return f"<span class='chip'>{_e(r['key'])}</span>", "<span class='bad'>no test</span>"
158
+ outs = {x["outcome"] for t in tests for x in results[t["id"]]}
159
+ mark = "<span class='ok'>✓</span>" if "pass" in outs and not outs & {"fail", "error"} else ("<span class='bad'>✗ failing</span>" if outs & {"fail", "error"} else "<span class='warn'>○ not run</span>")
160
+ return f"<span class='chip'>{_e(r['key'])}</span>", mark
161
+ for hz in sorted(by_kind["hazard"], key=lambda x: x["key"]):
162
+ direct = [l["from"] for l in in_l[hz["id"]] if l["type"] == "mitigates"]
163
+ cs = [ents[l["from"]] for l in in_l[hz["id"]] if l["type"] == "leads_to"]
164
+ rows_ = []
165
+ for rid in sorted(direct):
166
+ chip, mark = req_cell(rid); rows_.append(("<span class='muted'>(direct)</span>", chip, mark))
167
+ for c in sorted(cs, key=lambda x: x["key"]):
168
+ mits = sorted(l["from"] for l in in_l[c["id"]] if l["type"] == "mitigates")
169
+ label = f"<b>{_e(c['key'])}</b> {_e(c['title'])}"
170
+ if mits:
171
+ for rid in mits:
172
+ chip, mark = req_cell(rid); rows_.append((label, chip, mark))
173
+ elif c["accepted"]:
174
+ rows_.append((label, f"<span class='muted'>risk accepted: {_e(c['accepted']['justification'])}</span>", "<span class='muted'>—</span>"))
175
+ else:
176
+ rows_.append((label, "<span class='bad'>unmitigated</span>", "<span class='bad'>—</span>"))
177
+ if not rows_:
178
+ rows_.append(("<span class='muted'>—</span>", "<span class='bad'>unmitigated</span>", "<span class='bad'>—</span>"))
179
+ first = True
180
+ for label, chip, mark in rows_:
181
+ hcell = f"<td rowspan='{len(rows_)}'><b>{_e(hz['key'])}</b> {_e(hz['title'])}<div class='muted'>{_e(hz.get('severity') or '')}</div></td>" if first else ""
182
+ h.append(f"<tr>{hcell}<td>{label}</td><td>{chip}</td><td>{mark}</td></tr>"); first = False
183
+ h.append("</table></div>")
184
+
185
+ # Findings
186
+ h.append("<h2>Static analysis findings</h2>")
187
+ if by_kind["finding"]:
188
+ h.append("<div class='wrap'><table><tr><th>Rule</th><th>Location</th><th>Tool</th><th>State</th><th>Deviation</th></tr>")
189
+ for f in sorted(by_kind["finding"], key=lambda f: f["id"]):
190
+ devs = [ents[l["from"]] for l in in_l[f["id"]] if l["type"] == "justifies"]
191
+ dev_html = "<br>".join(f"<b>{_e(d['key'])}</b> {_e(d['justification'])} <span class='muted'>(expires {_e(d['expires_at'] or 'never')})</span>" for d in devs) or "—"
192
+ state_cls = "bad" if f["state"] == "open" else "muted"
193
+ loc = f"{f['location']['path']}:{f['location'].get('line', '?')}"
194
+ h.append(f"<tr><td><code>{_e(f['rule_id'])}</code><div class='muted'>{_e(f.get('message', ''))}</div></td><td><code>{_e(loc)}</code></td>"
195
+ f"<td>{_e(ents[f['tool']]['name'])}</td><td class='{state_cls}'>{_e(f['state'])}</td><td>{dev_html}</td></tr>")
196
+ h.append("</table></div>")
197
+ else:
198
+ h.append("<p class='muted'>None ingested.</p>")
199
+
200
+ # Tools and pre-existing software
201
+ h.append("<h2>Tools</h2><div class='wrap'><table><tr><th>Tool</th><th>Version</th><th>Class</th><th>Evidence</th></tr>")
202
+ for t in sorted(by_kind["tool"], key=lambda t: t["name"].lower()):
203
+ cls = _e(t["class"]) if t["class"] else "<span class='warn'>unclassified</span>"
204
+ h.append(f"<tr><td>{_e(t['name'])}</td><td><code>{_e(t['version'])}</code></td><td>{cls}</td><td>{_e(', '.join(t.get('evidence', []))) or '—'}</td></tr>")
205
+ h.append("</table></div>")
206
+ if by_kind["preexisting_element"]:
207
+ h.append("<h2>Pre-existing software</h2><div class='wrap'><table><tr><th>Element</th><th>Version</th><th>Route</th><th>Justification</th></tr>")
208
+ for x in by_kind["preexisting_element"]:
209
+ route = _e(x["route"]) if x["route"] else "<span class='warn'>not routed</span>"
210
+ h.append(f"<tr><td>{_e(x['name'])}</td><td><code>{_e(x['version'])}</code></td><td>{route}</td><td>{_e(', '.join(x.get('justification', []))) or '—'}</td></tr>")
211
+ h.append("</table></div>")
212
+
213
+ # Manifest
214
+ h.append("<h2>Artifact manifest</h2><div class='wrap'><table><tr><th>Path</th><th>Section</th><th>sha256</th></tr>")
215
+ for a in bundle["artifacts"]:
216
+ h.append(f"<tr><td><code>{_e(a['path'])}</code></td><td>{_e(a['section'])}</td><td><code>{_e(a['hash'][7:23])}…</code></td></tr>")
217
+ h.append("</table></div>")
218
+ if b.get("previous_bundle_hash"):
219
+ h.append(f"<p class='meta'>Chained to previous bundle <code>{_e(b['previous_bundle_hash'])}</code></p>")
220
+
221
+ if warnings:
222
+ h.append("<h2>Build warnings</h2><ul>" + "".join(f"<li class='warn'>{_e(w)}</li>" for w in warnings) + "</ul>")
223
+ h.append("</main></body></html>")
224
+ return "\n".join(h)