Unblocks the enforced Gitea gate, which correctly BLOCKED this branch on ['R-SEC-01','R-SEC-02','R-TEST-06'] being touched but uncovered. Tests were authored; the coverage rule was NOT narrowed. === THE FALSE GREEN (root cause) === `make -C ci gate` locally reported PASS having checked NOTHING. resolve_base() validated nothing, and EVERY way of failing to find a base degraded silently to an empty touched set + exit 0: an `HEAD~1` last resort, a two-dot diff fallback masking a failing three-dot diff, and no check that HEAD is even AHEAD of the base. Run while sitting on `main` (main == origin/main), `origin/main...HEAD` is empty -> "no changed files vs base" -> the empty set means run.sh is invoked with NO --requirements -> coverage.py's --require gate never runs at all. Green by vacuum. Now: candidates are BASE_REF > GITHUB_BASE_REF > origin/main > main, each verified to resolve. HEAD~1 and the two-dot fallback are gone. Unresolvable base / no merge base / failed diff / HEAD-not-ahead -> loud banner, EMPTY stdout, exit 3, and a machine-readable reason (on-base | no-base | diff-failed). gate.sh turns exit 3 into an UNDECIDED decision: `on-base` -> "NOT APPLICABLE — no branch under gate" (exit 0 so push-to-main runs don't break, but it never reads as PASS); `no-base`/`diff-failed` -> BLOCKED. It also now prints the diff base it used. A stale local main warns (widens, never falsely greens). Also fixed `make -C ci test`, which was SWALLOWING failing suites. Regression: ci/tests/touched-requirements-base.test.sh — 31 assertions over 8 throwaway-repo scenarios including the exact false-green case, an already-merged branch, no base ref, a single-commit repo, bogus BASE_REF, a shallow GITHUB_BASE_REF, and origin/main-preferred-over-stale-main. === NEW L0 STATIC LAYER (conformance/tests/l0-static/) === Where docs/14 actually puts these invariants. Each check.sh really executes offline via `run.sh --layer l0` and is wired into ci/l0.sh, so it runs on every PR. No scaffolds. R-SEC-01 no-plaintext-secrets (165 assertions / 445 tracked files): .sops.yaml holds only PUBLIC age recipients; every committed Secret is SOPS-wrapped with a mac and ENC[...] values; ansible vault.yml files are $ANSIBLE_VAULT; no secret-valued ansible key carries a literal outside a vault; no PEM key / age secret key / kubeconfig client-key-data is tracked; Flux declares sops decryption; the disallow-plaintext-secrets policy Enforces and has a proving `result: fail` row. Exemptions are SELF-POLICING, not path allowlists — a .example must be referenced by no kustomization and hold only placeholders, and the Kyverno fixture is exempt ONLY while a test asserts the policy rejects it. Proven against 7 injected violations, including deleting the negative row and weakening Enforce->Audit. R-SEC-02 least-privilege-rbac (41 assertions): no wildcard apiGroup/resource/verb and no escalate|bind|impersonate in any in-repo Role/ClusterRole; no ServiceAccount bound to cluster-admin or a wildcard role; the conformance runner's own test identity is scoped and holds no rights over RBAC objects; the default-deny/host-namespace/egress-class policies Enforce and each carry a negative row. Proven against 6 injected violations. R-TEST-06 spec-immutability-enforced (16 assertions): drives the REAL ci/spec-immutability.sh against HERMETIC throwaway spec repos and asserts its exit code in every state — clean-at-pin 0, edits under docs/ or decisions/ 1, untracked addition 1, unadopted drift 1, explicit pin bump 0, ABSENT spec repo 3 (honest skip, never green), unpinned SPEC_PIN 1 — plus that gate.sh sets BLOCK=1 on its failure, so the mechanism is load-bearing. The real spec repo is never written to. Each check documents what it does NOT cover (runtime decryption, git history, runtime least-privilege, branch protection) rather than overclaiming. status.py now reads check.sh too, so a placeholder L0 body still outranks an `executable: true` annotation. Coverage 37/51 -> 40/51 (11 gaps); executable tests 32 -> 35; scaffold-only stays 5 — no sixth scaffold was added. Serves: R-SEC-01, R-SEC-02, R-TEST-01..06. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
322 lines
13 KiB
Python
Executable File
322 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Requirement-coverage report generator (R-TEST-04).
|
|
|
|
Maps EVERY R-* requirement in the authoritative registry to the Chainsaw test(s) that
|
|
verify it, and flags requirements with no verifying test as visible, blocking gaps.
|
|
|
|
Single source of truth for coverage is the per-requirement label on each Chainsaw Test:
|
|
|
|
metadata:
|
|
labels:
|
|
conformance.home.k3s/R-NET-01: "true"
|
|
|
|
Reads:
|
|
* requirements/registry.yaml -- the mirror of every spec R-* ID (see that file).
|
|
* tests/**/chainsaw-test.yaml -- discovers labels + the requirements annotation.
|
|
|
|
Writes (under --out, default _artifacts/coverage/):
|
|
* coverage.json -- machine-readable {requirement: [tests...]}, gaps, safety gaps.
|
|
* coverage.md -- human-readable table for PRs / CI logs.
|
|
|
|
Exit status / gating (R-TEST-03 gates are *scoped*, not "all 51 always"):
|
|
0 always in plain report mode.
|
|
--strict HYGIENE gate (always-on, WS-TEST owns it): fails on tag/annotation
|
|
drift or tags absent from the registry. Does NOT fail merely because
|
|
a future-phase requirement has no test yet — those are visible gaps,
|
|
not harness defects.
|
|
--require IDs SCOPED completeness gate (merge gate): fails if any listed requirement
|
|
is uncovered, or a listed safety-critical requirement lacks an L3 test.
|
|
CI passes the requirement IDs the change touches.
|
|
--require-all FULL completeness gate (phase-exit / final): fails if ANY registry
|
|
requirement is uncovered or any safety-critical one lacks an L3 test.
|
|
--allow-gap IDs exempt specific IDs from the completeness gates (documented, out-of-
|
|
scope gaps such as R-ENV-02/R-GITOPS-01 owned by another workstream).
|
|
|
|
Pure standard library (no PyYAML) so the harness has no pip dependency: the registry and
|
|
the test label/annotation lines we care about are parsed with line-oriented regexes, and
|
|
the file formats are constrained (documented in registry.yaml / common.sh) to keep that
|
|
parse deterministic.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
LABEL_PREFIX = "conformance.home.k3s"
|
|
RID_RE = re.compile(r"R-[A-Z0-9]+-[0-9]+")
|
|
LABEL_LINE_RE = re.compile(
|
|
r"^\s*" + re.escape(LABEL_PREFIX) + r"/(R-[A-Z0-9]+-[0-9]+)\s*:\s*[\"']?true", re.I
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- registry
|
|
def parse_registry(path: Path) -> list[dict]:
|
|
"""Parse the constrained registry.yaml (`- id:` blocks, single-line scalars)."""
|
|
reqs: list[dict] = []
|
|
cur: dict | None = None
|
|
for raw in path.read_text().splitlines():
|
|
line = raw.split("#", 1)[0] if raw.lstrip().startswith("#") else raw
|
|
m = re.match(r"^\s*-\s*id:\s*(\S+)", line)
|
|
if m:
|
|
if cur:
|
|
reqs.append(cur)
|
|
cur = {"id": m.group(1).strip(), "doc": "", "safety_critical": False,
|
|
"summary": ""}
|
|
continue
|
|
if cur is None:
|
|
continue
|
|
m = re.match(r"^\s+doc:\s*(.+?)\s*$", line)
|
|
if m:
|
|
cur["doc"] = m.group(1).strip().strip('"')
|
|
continue
|
|
m = re.match(r"^\s+safety_critical:\s*(true|false)\s*$", line)
|
|
if m:
|
|
cur["safety_critical"] = (m.group(1) == "true")
|
|
continue
|
|
m = re.match(r"^\s+summary:\s*(.+?)\s*$", line)
|
|
if m:
|
|
cur["summary"] = m.group(1).strip().strip('"')
|
|
if cur:
|
|
reqs.append(cur)
|
|
return reqs
|
|
|
|
|
|
# ------------------------------------------------------------------------------ tests
|
|
def parse_test(path: Path) -> dict:
|
|
"""Extract name, verified R-IDs (from labels), annotation list, and layer."""
|
|
text = path.read_text()
|
|
labels: set[str] = set()
|
|
for line in text.splitlines():
|
|
m = LABEL_LINE_RE.match(line)
|
|
if m:
|
|
labels.add(m.group(1).upper())
|
|
|
|
ann_reqs: set[str] = set()
|
|
m = re.search(r"^\s*" + re.escape(LABEL_PREFIX) + r"/requirements:\s*(.+)$",
|
|
text, re.M)
|
|
if m:
|
|
ann_reqs = {r.upper() for r in RID_RE.findall(m.group(1))}
|
|
|
|
layer = ""
|
|
m = re.search(r"^\s*" + re.escape(LABEL_PREFIX) + r"/layer:\s*[\"']?([LlAaMm0-9]+)",
|
|
text, re.M)
|
|
if m:
|
|
layer = m.group(1).upper()
|
|
|
|
name = ""
|
|
m = re.search(r"^\s*name:\s*(\S+)", text, re.M)
|
|
if m:
|
|
name = m.group(1)
|
|
|
|
# Layer inferred from path as a fallback / cross-check.
|
|
rel = str(path)
|
|
if not layer:
|
|
if "l0-static" in rel:
|
|
layer = "L0"
|
|
elif "l1-rendering" in rel:
|
|
layer = "L1"
|
|
elif "l2-behavioral" in rel:
|
|
layer = "L2"
|
|
elif "l3-fault-injection" in rel:
|
|
layer = "L3"
|
|
elif "/meta/" in rel:
|
|
layer = "META"
|
|
|
|
return {
|
|
"name": name,
|
|
"path": rel,
|
|
"labels": sorted(labels),
|
|
"annotation_requirements": sorted(ann_reqs),
|
|
"layer": layer,
|
|
}
|
|
|
|
|
|
def discover_tests(tests_dir: Path) -> list[dict]:
|
|
return [parse_test(p) for p in sorted(tests_dir.rglob("chainsaw-test.yaml"))]
|
|
|
|
|
|
# ----------------------------------------------------------------------------- report
|
|
def build(registry: list[dict], tests: list[dict]) -> dict:
|
|
by_req: dict[str, list[dict]] = {r["id"]: [] for r in registry}
|
|
unknown: list[tuple[str, str]] = [] # (rid, test path) not in registry
|
|
mismatches: list[dict] = [] # label set != annotation set
|
|
|
|
for t in tests:
|
|
lbl = set(t["labels"])
|
|
ann = set(t["annotation_requirements"])
|
|
if lbl != ann:
|
|
mismatches.append({"test": t["path"], "labels": sorted(lbl),
|
|
"annotation": sorted(ann)})
|
|
for rid in lbl:
|
|
if rid in by_req:
|
|
by_req[rid].append(t)
|
|
else:
|
|
unknown.append((rid, t["path"]))
|
|
|
|
reg_index = {r["id"]: r for r in registry}
|
|
covered, uncovered, safety_gaps = [], [], []
|
|
for rid, req in reg_index.items():
|
|
verifiers = by_req[rid]
|
|
if verifiers:
|
|
covered.append(rid)
|
|
else:
|
|
uncovered.append(rid)
|
|
if req["safety_critical"]:
|
|
has_l3 = any(v["layer"] == "L3" for v in verifiers)
|
|
if not has_l3:
|
|
safety_gaps.append(rid)
|
|
|
|
return {
|
|
"summary": {
|
|
"total_requirements": len(registry),
|
|
"covered": len(covered),
|
|
"uncovered": len(uncovered),
|
|
"coverage_pct": round(100.0 * len(covered) / max(1, len(registry)), 1),
|
|
"safety_critical_gaps": len(safety_gaps),
|
|
"annotation_label_mismatches": len(mismatches),
|
|
"unknown_requirement_tags": len(unknown),
|
|
"total_tests": len(tests),
|
|
},
|
|
"by_requirement": {
|
|
r["id"]: {
|
|
"doc": r["doc"],
|
|
"safety_critical": r["safety_critical"],
|
|
"summary": r["summary"],
|
|
"verified_by": [
|
|
{"name": v["name"], "path": v["path"], "layer": v["layer"]}
|
|
for v in by_req[r["id"]]
|
|
],
|
|
}
|
|
for r in registry
|
|
},
|
|
"uncovered": sorted(uncovered),
|
|
"safety_critical_gaps": sorted(safety_gaps),
|
|
"annotation_label_mismatches": mismatches,
|
|
"unknown_requirement_tags": [{"requirement": u[0], "test": u[1]} for u in unknown],
|
|
}
|
|
|
|
|
|
def render_md(rep: dict) -> str:
|
|
s = rep["summary"]
|
|
out: list[str] = []
|
|
out.append("# Requirement coverage report\n")
|
|
out.append(
|
|
f"- Requirements: **{s['total_requirements']}** | "
|
|
f"Covered: **{s['covered']}** ({s['coverage_pct']}%) | "
|
|
f"Uncovered: **{s['uncovered']}**\n"
|
|
f"- Safety-critical requirements missing an L3 fault-injection test: "
|
|
f"**{s['safety_critical_gaps']}**\n"
|
|
f"- Scaffolded tests: **{s['total_tests']}** | "
|
|
f"Annotation/label mismatches: **{s['annotation_label_mismatches']}** | "
|
|
f"Unknown tags: **{s['unknown_requirement_tags']}**\n"
|
|
)
|
|
out.append("\n> Phase 0: tests are scaffolds. A requirement is **satisfied** only "
|
|
"when its tagged test passes green against a cluster (R-TEST-03) — this "
|
|
"report tracks *scaffold* coverage, i.e. that a verifying test exists.\n")
|
|
|
|
out.append("\n## Coverage by requirement\n")
|
|
out.append("| Requirement | Safety | Verified by (layer) | Status |")
|
|
out.append("|---|---|---|---|")
|
|
for rid, info in rep["by_requirement"].items():
|
|
vs = info["verified_by"]
|
|
if vs:
|
|
cell = "<br>".join(f"`{v['name']}` ({v['layer']})" for v in vs)
|
|
status = "covered"
|
|
else:
|
|
cell = "—"
|
|
status = "**GAP**"
|
|
sc = "yes" if info["safety_critical"] else ""
|
|
out.append(f"| {rid} | {sc} | {cell} | {status} |")
|
|
|
|
if rep["uncovered"]:
|
|
out.append("\n## Uncovered requirements (gaps)\n")
|
|
for rid in rep["uncovered"]:
|
|
out.append(f"- {rid} — {rep['by_requirement'][rid]['summary']}")
|
|
if rep["safety_critical_gaps"]:
|
|
out.append("\n## Safety-critical requirements without an L3 fault-injection test "
|
|
"(R-TEST-05)\n")
|
|
for rid in rep["safety_critical_gaps"]:
|
|
out.append(f"- {rid}")
|
|
if rep["annotation_label_mismatches"]:
|
|
out.append("\n## Tests whose labels disagree with their `requirements` annotation\n")
|
|
for m in rep["annotation_label_mismatches"]:
|
|
out.append(f"- `{m['test']}` labels={m['labels']} annotation={m['annotation']}")
|
|
if rep["unknown_requirement_tags"]:
|
|
out.append("\n## Tags not present in the registry (typo or spec drift)\n")
|
|
for u in rep["unknown_requirement_tags"]:
|
|
out.append(f"- {u['requirement']} in `{u['test']}`")
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
here = Path(__file__).resolve().parent.parent # conformance/
|
|
ap.add_argument("--registry", type=Path,
|
|
default=here / "requirements" / "registry.yaml")
|
|
ap.add_argument("--tests", type=Path, default=here / "tests")
|
|
ap.add_argument("--out", type=Path, default=here / "_artifacts" / "coverage")
|
|
ap.add_argument("--strict", action="store_true",
|
|
help="hygiene gate: fail on tag/annotation drift or unknown tags")
|
|
ap.add_argument("--require", default="",
|
|
help="comma/space list of R-IDs that MUST be covered (merge gate)")
|
|
ap.add_argument("--require-all", action="store_true",
|
|
help="every registry requirement MUST be covered (phase-exit gate)")
|
|
ap.add_argument("--allow-gap", default="",
|
|
help="comma/space list of R-IDs exempt from completeness gates")
|
|
args = ap.parse_args()
|
|
|
|
registry = parse_registry(args.registry)
|
|
tests = discover_tests(args.tests)
|
|
rep = build(registry, tests)
|
|
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
(args.out / "coverage.json").write_text(json.dumps(rep, indent=2) + "\n")
|
|
(args.out / "coverage.md").write_text(render_md(rep))
|
|
|
|
s = rep["summary"]
|
|
print(f"[coverage] {s['covered']}/{s['total_requirements']} requirements have a "
|
|
f"scaffolded test ({s['coverage_pct']}%); {s['uncovered']} gap(s), "
|
|
f"{s['safety_critical_gaps']} safety-critical gap(s).")
|
|
print(f"[coverage] wrote {args.out/'coverage.md'} and {args.out/'coverage.json'}")
|
|
|
|
def split(v):
|
|
return {t.upper() for t in re.split(r"[,\s]+", v) if t}
|
|
|
|
exempt = split(args.allow_gap)
|
|
uncovered = set(rep["uncovered"])
|
|
safety_gaps = set(rep["safety_critical_gaps"])
|
|
rc = 0
|
|
|
|
# HYGIENE (always-on): things WS-TEST controls and must keep green.
|
|
if args.strict:
|
|
if rep["annotation_label_mismatches"] or rep["unknown_requirement_tags"]:
|
|
print("[coverage] STRICT(hygiene): FAILED — label/annotation drift or "
|
|
"tags not in the registry.", file=sys.stderr)
|
|
rc = 1
|
|
|
|
# SCOPED completeness (merge gate): only the requirements the change touches.
|
|
required = split(args.require)
|
|
if args.require_all:
|
|
required |= {r["id"] for r in registry}
|
|
required -= exempt
|
|
if required:
|
|
miss = sorted(required & uncovered)
|
|
sgap = sorted(required & safety_gaps)
|
|
if miss:
|
|
print(f"[coverage] REQUIRE: FAILED — required requirement(s) uncovered: "
|
|
f"{miss}", file=sys.stderr)
|
|
rc = 1
|
|
if sgap:
|
|
print(f"[coverage] REQUIRE: FAILED — required safety-critical requirement(s) "
|
|
f"lack an L3 fault-injection test: {sgap}", file=sys.stderr)
|
|
rc = 1
|
|
return rc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|