diff --git a/hydra-gates/scripts/lib/check_spec_screens.py b/hydra-gates/scripts/lib/check_spec_screens.py new file mode 100644 index 00000000..bc6228be --- /dev/null +++ b/hydra-gates/scripts/lib/check_spec_screens.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Conduction +# SPDX-License-Identifier: EUPL-1.2 +""" +gate-118: spec-screens. Every OpenSpec change and spec a PR touches names its +screens. + +WHAT THIS CHECKS +---------------- +The fleet's screen designs ("boards") live in ConductionNL/design-system under +screens-src/zuiddrecht/.dc.html. Each app registers its boards in +screens-src/zuiddrecht/apps/.json, and the published set is at +https://identity.conduction.nl/screens. Hydra's "screens first" ADR makes the +board the UI canon: a change says which boards it builds, or says why it has +none. + +That statement lives in one file per directory, `screens.md`, next to the +change or spec it belongs to: + + # Screens + + - DqZaaktypen https://identity.conduction.nl/screens/board?id=dossiq/DqZaaktypen + - No screen: a background job that sends reminder mail; no page changes + - Design backlog: DqZaakTermijnen (decision 157) + +For every directory the PR touches, of these three shapes: + + openspec/changes// + openspec/changes/archive// (a change being archived) + openspec/specs// + +this checker asks five things: + + 1. screens.md exists, and has at least one `- ` line. + 2. every `- ` line names a board that exists on design-system main: + a key of `boards` in the published index preview/screens/screens.json + (what identity.conduction.nl/screens serves), or a board in the app's own + registration file screens-src/zuiddrecht/apps/.json, so a board + registered but not yet built into the index still counts. A board whose + file screens-src/zuiddrecht/.dc.html is on main counts too, for + a board merged before the index was rebuilt. Any app's board + may be cited: shared boards live under other ids (launchpad's LpStart is + `werkplek/LpStart`, the shared integrations page is a pipelinq board). + 3. a `- No screen: ` line carries a real reason. A reason that only + says the design is not there yet ("not designed yet", "no board", + "design session") is not a reason, it is a missing board: put it on the + design backlog instead (rule 4). The list is + design-system's own PLACEHOLDER_REASON, from scripts/screens/ + capabilities.py, copied verbatim so the gate and the board index agree on + what a placeholder is. + 4. a `- Design backlog: (decision 157)` line passes. It + marks real UI whose board is not drawn yet, and names the board it + proposes; only a line that names nothing is a finding. + 5. a `- No board found yet` line is a finding. The generator writes that line + where it found nothing (decision 150), and a directory a PR touches has to + settle it: name the board, add one in a paired design-system PR, or give a + real no-screen reason. + +DIFF-SCOPED, AND BLOCKING FROM DAY ONE +-------------------------------------- +Board names are validated, not URL text: the first word of a board line is +the board, and the link after it (`https://identity.conduction.nl/screens/ +board?id=/`) is for the reader. + +Only directories the change touches are judged (decision 151). There is no +warning period: the gate is meant to land after every app's generated +screens.md has landed, so a red here is always this PR's own directory. + +With `--full-tree` (the runner passes it when it audits against the empty +tree, a push whose previous tip is unknown) every live change and spec +directory is judged and archive/ is not: an archived change written before +screens.md existed is history, not a delivery. + +HOW THE BOARD LIST IS READ +-------------------------- +Read-only, from design-system `main`, once per run: + + HYDRA_GATE_SCREENS_SOURCE a local directory laid out like the design-system + repo root (tests, offline runs, a sparse clone) + HYDRA_GATE_SCREENS_REPO_URL the raw base URL, default raw.githubusercontent.com + for ConductionNL/design-system main + HYDRA_GATE_SCREENS_APP the app name, when neither info.xml nor the + repo name matches an apps/.json file + +WHY THE INDEX AND NOT ONLY apps/.json. The per-app file is one of several +registration sources build.py merges (rows1/rows2.json and the canvases are the +others). Measured 2026-10-10 on the generated screens.md of dossiq: 185 board +lines named boards that exist and are published, DqZaak among them, but are not +in apps/dossiq.json. Checked against that file alone, the gate would have +failed real boards. + +The app name for the optional app file is tried in this order: +HYDRA_GATE_SCREENS_APP, in appinfo/info.xml, the repository name from +GITHUB_REPOSITORY, the origin remote, the directory name. The first one +design-system has a file for wins. App ids are still moving through the fleet +rename, so the info.xml id and the repo name can differ. + +A NETWORK FAILURE IS NOT A FINDING +---------------------------------- +If design-system cannot be reached, the board lines are unverified. They are +reported as such and the run exits 2 (no verdict) unless a finding that needs +no network already fails it. + +Exit codes, the package's status protocol: + 0 every judged directory is clean + 1 at least one finding (count on the summary line) + 2 error: the board list could not be read, or the app dir is unreadable + 3 empty scope: the change touched no openspec change or spec directory + 4 not applicable: no openspec/changes or openspec/specs in this repo + +Usage: + printf '%s\\n' | check_spec_screens.py --changed-stdin + check_spec_screens.py --full-tree +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from exclusion_reason import is_reason_bearing # noqa: E402 + +GATE_NUM = 118 +GATE_NAME = "spec-screens" + +EXIT_PASS = 0 +EXIT_FAIL = 1 +EXIT_ERROR = 2 +EXIT_EMPTY_SCOPE = 3 +EXIT_NOT_APPLICABLE = 4 + +DEFAULT_REPO_URL = "https://raw.githubusercontent.com/ConductionNL/design-system/main" + +# Copied verbatim from ConductionNL/design-system scripts/screens/capabilities.py +# (PLACEHOLDER_REASON). Keep the two in step: the board index and this gate +# must agree on which reasons only say "not designed yet". +PLACEHOLDER_REASON = re.compile( + r'not (yet )?designed|not drawn|no board|missing-boards|design session|decision 96|' + r'nog niet ontworpen|niet getekend|geen bord|no (design|screen) (yet|drawn)', re.I) + +# A no-screen reason has to name something. Ten characters is gate-57's floor +# and the one exclusion_reason documents for reasons that must describe a +# subject, not just exist. +REASON_MIN_CHARS = 10 + +NO_SCREEN_RE = re.compile(r"^no screen\s*:\s*(.*)$", re.I) +NO_BOARD_RE = re.compile(r"^no board found", re.I) +# Decision 157: real UI whose board is not drawn yet goes on the design backlog, +# naming the board it proposes. That passes; the board is owed, and named. +BACKLOG_RE = re.compile(r"^design backlog\s*:\s*(.*)$", re.I) + + +def scope_dir(path: str) -> str | None: + """The change or spec directory a changed path belongs to, or None.""" + parts = path.strip().strip("/").split("/") + if len(parts) < 3 or parts[0] != "openspec": + return None + if parts[1] == "specs" and len(parts) >= 4: + return "/".join(parts[:3]) + if parts[1] == "changes": + if parts[2] == "archive": + return "/".join(parts[:4]) if len(parts) >= 5 else None + if len(parts) >= 4: + return "/".join(parts[:3]) + return None + + +def all_live_dirs(app_dir: Path) -> list[str]: + out: list[str] = [] + for sub in ("changes", "specs"): + root = app_dir / "openspec" / sub + if not root.is_dir(): + continue + for child in sorted(root.iterdir()): + if child.is_dir() and not (sub == "changes" and child.name == "archive"): + out.append(f"openspec/{sub}/{child.name}") + return out + + +def norm_board(token: str) -> str: + token = token.strip().strip("`*").strip() + token = token.split("/")[-1] + return re.sub(r"\.dc\.html?$", "", token) + + +def parse_screens(text: str) -> list[tuple[int, str]]: + """Return (line number, bullet body) for every `- ` line.""" + out = [] + for n, raw in enumerate(text.splitlines(), start=1): + line = raw.strip() + if line.startswith("- ") or line.startswith("* "): + out.append((n, line[2:].strip())) + return out + + +def app_candidates(app_dir: Path) -> list[str]: + names: list[str] = [] + + def add(v: str | None) -> None: + if not v: + return + v = v.strip().lower() + if v.endswith(".git"): + v = v[:-4] + v = v.rstrip("/").split("/")[-1] + if v and v not in names: + names.append(v) + + add(os.environ.get("HYDRA_GATE_SCREENS_APP")) + info = app_dir / "appinfo" / "info.xml" + if info.is_file(): + m = re.search(r"\s*([^<\s]+)\s*", info.read_text(encoding="utf-8", errors="replace")) + if m: + add(m.group(1)) + add(os.environ.get("GITHUB_REPOSITORY")) + try: + r = subprocess.run(["git", "-C", str(app_dir), "remote", "get-url", "origin"], + capture_output=True, text=True, timeout=10) + if r.returncode == 0: + add(r.stdout) + except (OSError, subprocess.SubprocessError): + pass + add(app_dir.resolve().name) + return names + + +class BoardSource: + """Reads design-system once per run: the published index, plus the app's own file.""" + + INDEX = "preview/screens/screens.json" + APP_FILE = "screens-src/zuiddrecht/apps/{app}.json" + + def __init__(self) -> None: + self.local = os.environ.get("HYDRA_GATE_SCREENS_SOURCE") + self.url = os.environ.get("HYDRA_GATE_SCREENS_REPO_URL", DEFAULT_REPO_URL).rstrip("/") + self.cache: dict[str, tuple[str, dict]] = {} + self.errors: list[str] = [] + + def _read(self, rel: str) -> tuple[str, dict]: + """('ok', doc) | ('absent', {}) | ('error', {}). Cached per path.""" + if rel in self.cache: + return self.cache[rel] + result: tuple[str, dict] = ("error", {}) + if self.local: + p = Path(self.local) / rel + if not p.is_file(): + result = ("absent", {}) + else: + try: + result = ("ok", json.loads(p.read_text(encoding="utf-8"))) + except (OSError, ValueError) as e: + self.errors.append(f"{p}: {e}") + else: + url = f"{self.url}/{rel}" + req = urllib.request.Request(url, headers={"User-Agent": "hydra-gates-spec-screens"}) + err = "" + for _ in range(3): + try: + with urllib.request.urlopen(req, timeout=30) as resp: + result = ("ok", json.loads(resp.read().decode("utf-8"))) + break + except urllib.error.HTTPError as e: + if e.code == 404: + result = ("absent", {}) + break + err = f"{url}: HTTP {e.code}" + except (urllib.error.URLError, OSError, ValueError) as e: + err = f"{url}: {e}" + if result[0] == "error": + self.errors.append(err) + self.cache[rel] = result + return result + + def board_file_exists(self, board: str) -> str: + """'ok' | 'absent' | 'error' for screens-src/zuiddrecht/.dc.html. + + The fallback for a board drawn and merged on main whose index has not + been rebuilt yet and that no apps/.json lists. + """ + rel = f"screens-src/zuiddrecht/{board}.dc.html" + if rel in self.cache: + return self.cache[rel][0] + status = "error" + if self.local: + status = "ok" if (Path(self.local) / rel).is_file() else "absent" + else: + req = urllib.request.Request(f"{self.url}/{rel}", method="HEAD", + headers={"User-Agent": "hydra-gates-spec-screens"}) + for _ in range(3): + try: + with urllib.request.urlopen(req, timeout=30): + status = "ok" + break + except urllib.error.HTTPError as e: + if e.code == 404: + status = "absent" + break + except (urllib.error.URLError, OSError): + pass + self.cache[rel] = (status, {}) + return status + + @staticmethod + def _app_boards(doc: dict) -> set[str]: + names = {norm_board(k) for k in (doc.get("boards") or {})} + + def walk(node: object) -> None: + # rows nest as [id, title, [[board, ...], ...]]; collect every board file name. + if isinstance(node, list): + for item in node: + walk(item) + elif isinstance(node, str) and node.endswith(".dc.html"): + names.add(norm_board(node)) + + walk(doc.get("rows") or []) + return names + + def known_boards(self, candidates: list[str]) -> tuple[bool, set[str], str | None]: + """(verified, board names, app file used). + + The published index is the authority for "this board exists". The + app's own registration file is added on top, so a board registered on + design-system main but not yet built into the index still counts. + """ + names: set[str] = set() + verified = False + status, index = self._read(self.INDEX) + if status == "ok": + verified = True + # Keys are not always the board name: the school sets key a board as + # `-` with id `/`. So the id counts too. + for key, meta in (index.get("boards") or {}).items(): + names.add(norm_board(key)) + if isinstance(meta, dict) and isinstance(meta.get("id"), str): + names.add(norm_board(meta["id"])) + elif status == "absent": + self.errors.append(f"{self.INDEX} is missing on design-system main") + used = None + for c in candidates: + st, doc = self._read(self.APP_FILE.format(app=c)) + if st == "ok": + names |= self._app_boards(doc) + used = c + verified = True + break + return verified, names, used + + +def run(app_dir: Path, dirs: list[str]) -> int: + source = BoardSource() + candidates = app_candidates(app_dir) + known: tuple[bool, set[str], str | None] | None = None + + findings: list[str] = [] + unverified: list[str] = [] + counts = {"dirs": 0, "boards": 0, "noscreen": 0, "backlog": 0} + + for d in dirs: + counts["dirs"] += 1 + screens = app_dir / d / "screens.md" + rel = f"{d}/screens.md" + if not screens.is_file(): + findings.append( + f"{d}/: no screens.md. Name the boards this builds (`- `), " + f"or `- No screen: ` when nothing a user sees changes.") + continue + bullets = parse_screens(screens.read_text(encoding="utf-8", errors="replace")) + if not bullets: + findings.append(f"{rel}: has no `- ` line, so it names neither a board nor a reason.") + continue + for n, body in bullets: + if NO_BOARD_RE.match(body): + findings.append( + f"{rel}:{n}: `No board found yet`. This PR touches the directory, so settle it: " + f"name the board, add one in a paired design-system PR, put it on the design backlog " + f"(`- Design backlog: (decision 157)`), or give a real no-screen reason.") + continue + bm = BACKLOG_RE.match(body) + if bm: + counts["backlog"] += 1 + proposed = re.sub(r"\(decision 157\)\s*$", "", bm.group(1), flags=re.I).strip() + if not is_reason_bearing(proposed): + findings.append( + f"{rel}:{n}: `Design backlog` without the board it proposes. " + f"Write `- Design backlog: (decision 157)`.") + continue + m = NO_SCREEN_RE.match(body) + if m: + counts["noscreen"] += 1 + reason = m.group(1).strip() + if not is_reason_bearing(reason, min_chars=REASON_MIN_CHARS): + findings.append(f"{rel}:{n}: `No screen` without a real reason ({reason!r}).") + elif PLACEHOLDER_REASON.search(reason): + findings.append( + f"{rel}:{n}: `No screen: {reason}` only says the design is not there yet. " + f"That is a missing board: draw it in a paired design-system PR, or write " + f"`- Design backlog: (decision 157)`.") + continue + counts["boards"] += 1 + words = body.split() + board = norm_board(words[0]) + if known is None: + known = source.known_boards(candidates) + verified, boards, _used = known + if not verified: + unverified.append(f"{rel}:{n}: board {board!r} not verified, design-system was unreachable.") + continue + if board not in boards and "/" not in words[0]: + fstatus = source.board_file_exists(board) + if fstatus == "ok": + continue + if fstatus == "error": + unverified.append(f"{rel}:{n}: board {board!r} not verified, design-system was unreachable.") + continue + if board not in boards: + findings.append( + f"{rel}:{n}: board {board!r} is not on design-system main (not in " + f"preview/screens/screens.json, not in the app's screens-src/zuiddrecht/apps/ file, " + f"and no screens-src/zuiddrecht/{board}.dc.html). " + f"Use a board that exists, or add it in a paired design-system PR.") + + for f in findings: + print(f" {f}") + for u in unverified: + print(f" UNVERIFIED {u}") + for e in source.errors: + print(f" design-system read error: {e}") + print(f"[gate-{GATE_NUM}] {GATE_NAME}: checked {counts['dirs']} dir(s), " + f"{counts['boards']} board line(s), {counts['noscreen']} no-screen line(s), " + f"{counts['backlog']} design-backlog line(s), " + f"{len(findings)} finding(s), {len(unverified)} unverified.") + if findings: + print(f"[gate-{GATE_NUM}] {GATE_NAME}: FAIL: {len(findings)} finding(s)") + return EXIT_FAIL + if unverified: + print(f"[gate-{GATE_NUM}] {GATE_NAME}: NO VERDICT: {len(unverified)} board line(s) " + f"could not be checked against design-system") + return EXIT_ERROR + return EXIT_PASS + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("app_dir") + mode = ap.add_mutually_exclusive_group(required=True) + mode.add_argument("--changed-stdin", action="store_true", + help="read the changed paths, one per line, from stdin") + mode.add_argument("--full-tree", action="store_true", + help="judge every live change and spec directory (archive/ excluded)") + args = ap.parse_args(argv) + + app_dir = Path(args.app_dir) + if not app_dir.is_dir(): + print(f"[gate-{GATE_NUM}] {GATE_NAME}: ERROR: {app_dir} is not a readable directory.") + return EXIT_ERROR + if not (app_dir / "openspec" / "changes").is_dir() and not (app_dir / "openspec" / "specs").is_dir(): + print(f"[gate-{GATE_NUM}] {GATE_NAME}: NOT APPLICABLE: no openspec/changes or openspec/specs.") + return EXIT_NOT_APPLICABLE + + if args.full_tree: + dirs = all_live_dirs(app_dir) + else: + seen: list[str] = [] + for line in sys.stdin.read().splitlines(): + d = scope_dir(line) + # A directory the change deleted, or moved away (a change being + # archived leaves its old path behind), is not judged at its old path. + if d and d not in seen and (app_dir / d).is_dir(): + seen.append(d) + dirs = seen + if not dirs: + print(f"[gate-{GATE_NUM}] {GATE_NAME}: EMPTY SCOPE: no openspec change or spec directory in scope.") + return EXIT_EMPTY_SCOPE + return run(app_dir, dirs) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/hydra-gates/scripts/lib/test_check_spec_screens.py b/hydra-gates/scripts/lib/test_check_spec_screens.py new file mode 100644 index 00000000..d539067d --- /dev/null +++ b/hydra-gates/scripts/lib/test_check_spec_screens.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Conduction +# SPDX-License-Identifier: EUPL-1.2 +"""Tests for gate-118's checker (check_spec_screens.py). + +The gate asks whether every OpenSpec change and spec directory a PR touches +names its boards in screens.md. Every arm runs offline against a local copy of +the design-system layout (HYDRA_GATE_SCREENS_SOURCE), so no arm depends on the +network or on what design-system main holds today. + + 1. A CLEAN DIRECTORY PASSES: a board in the published index, a board only in + the app's own registration file, a board another app owns, and a real + no-screen reason. + 2. A MISSING screens.md FAILS, and so does one with no `- ` line. + 3. A BOARD NOBODY DREW FAILS, named by board and line. + 4. `No board found yet` FAILS in a directory the change touches. + 5. A PLACEHOLDER REASON FAILS: every shape in design-system's own list, plus + a bare and a too-short reason. The control arm keeps a real reason that + happens to contain the word "design" green. + 6. SCOPE: only touched directories are judged; a deleted or moved-away + directory is not judged at its old path; archive/ is judged when touched + and skipped by --full-tree. + 7. NO VERDICT WITHOUT THE BOARD LIST: an unreachable design-system is exit 2, + never a pass and never a finding. A finding that needs no network still + fails. + 8. THE DESIGN BACKLOG PASSES (decision 157): `- Design backlog: ` marks real UI whose board is not drawn yet. It passes, beside an + unknown board, a placeholder reason and `No board found yet`, which still + fail in the same file. A backlog line naming nothing fails. + 9. BOARD NAMES ARE VALIDATED, NOT URL TEXT: a real board with a link whose + id points elsewhere passes; an unknown board with a real board's link fails. + 10. NOTHING TO JUDGE is exit 3 (empty scope) or 4 (no openspec), never 0. + +Run: python3 scripts/lib/test_check_spec_screens.py +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +CHECKER = os.path.join(HERE, "check_spec_screens.py") + +INDEX = { + "boards": { + "DqZaak": {"id": "dossiq/DqZaak", "app": "dossiq"}, + "LpStart": {"id": "werkplek/LpStart", "app": "werkplek"}, + "wilgenboom-Artikel": {"id": "wilgenboom/Artikel", "app": "wilgenboom"}, + } +} +APP_FILE = { + "rows": [["rowFixture", "fixture", [["DqNieuw.dc.html"]]]], + "boards": {"DqZaaktypen.dc.html": {"title": "zaaktypen"}}, +} + +CLEAN = """# Screens + +- DqZaak https://identity.conduction.nl/screens/board?id=dossiq/DqZaak +- DqZaaktypen https://identity.conduction.nl/screens/board?id=dossiq/DqZaaktypen +- DqNieuw +- LpStart https://identity.conduction.nl/screens/board?id=werkplek/LpStart +- No screen: a nightly job that mails reminders, no page changes +""" + + +class SpecScreensTest(unittest.TestCase): + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = self._tmp.name + self.ds = os.path.join(self.root, "ds") + self.app = os.path.join(self.root, "dossiq") + self.write(self.ds, "preview/screens/screens.json", json.dumps(INDEX)) + self.write(self.ds, "screens-src/zuiddrecht/apps/dossiq.json", json.dumps(APP_FILE)) + self.write(self.app, "appinfo/info.xml", "procest\n") + os.makedirs(os.path.join(self.app, "openspec/specs"), exist_ok=True) + + def tearDown(self): + self._tmp.cleanup() + + @staticmethod + def write(base, rel, text): + path = os.path.join(base, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + + def run_check(self, changed, *, full=False, source=None): + env = {k: v for k, v in os.environ.items() if not k.startswith("HYDRA_GATE_SCREENS")} + env.pop("GITHUB_REPOSITORY", None) + env["HYDRA_GATE_SCREENS_SOURCE"] = self.ds if source is None else source + args = [sys.executable, CHECKER, self.app, "--full-tree" if full else "--changed-stdin"] + proc = subprocess.run(args, input="\n".join(changed) + "\n", capture_output=True, + text=True, timeout=60, env=env) + return proc.returncode, proc.stdout + proc.stderr + + # -- 1 --------------------------------------------------------------- + + def test_clean_directory_passes(self): + self.write(self.app, "openspec/changes/add-x/proposal.md", "x\n") + self.write(self.app, "openspec/changes/add-x/screens.md", CLEAN) + rc, out = self.run_check(["openspec/changes/add-x/proposal.md"]) + self.assertEqual(rc, 0, out) + self.assertIn("checked 1 dir(s), 4 board line(s), 1 no-screen line(s), 0 design-backlog line(s), 0 finding(s)", out) + + # -- 2 --------------------------------------------------------------- + + def test_missing_screens_md_fails(self): + self.write(self.app, "openspec/specs/cases/spec.md", "x\n") + rc, out = self.run_check(["openspec/specs/cases/spec.md"]) + self.assertEqual(rc, 1, out) + self.assertIn("openspec/specs/cases/: no screens.md", out) + + def test_screens_md_without_a_line_fails(self): + self.write(self.app, "openspec/specs/cases/screens.md", "# Screens\n\nTo do.\n") + rc, out = self.run_check(["openspec/specs/cases/screens.md"]) + self.assertEqual(rc, 1, out) + self.assertIn("has no `- ` line", out) + + # -- 3 --------------------------------------------------------------- + + def test_unknown_board_fails_by_name(self): + self.write(self.app, "openspec/specs/cases/screens.md", "# Screens\n\n- DqZaak\n- DqNope https://x/?id=dossiq/DqNope\n") + rc, out = self.run_check(["openspec/specs/cases/screens.md"]) + self.assertEqual(rc, 1, out) + self.assertIn("screens.md:4: board 'DqNope' is not on design-system main", out) + self.assertNotIn("'DqZaak'", out) + + # -- 4 --------------------------------------------------------------- + + def test_no_board_found_yet_fails(self): + self.write(self.app, "openspec/specs/cases/screens.md", "# Screens\n\n- No board found yet (decision 150)\n") + rc, out = self.run_check(["openspec/specs/cases/screens.md"]) + self.assertEqual(rc, 1, out) + self.assertIn("`No board found yet`", out) + + # -- 5 --------------------------------------------------------------- + + def test_placeholder_reasons_fail(self): + reasons = [ + "not designed yet", "not yet designed", "not drawn", "no board for this", + "see missing-boards", "waiting for the design session", "decision 96 applies", + "nog niet ontworpen", "niet getekend", "geen bord", "no design yet", + "no screen drawn", + ] + body = "# Screens\n\n" + "".join(f"- No screen: {r}\n" for r in reasons) + self.write(self.app, "openspec/specs/cases/screens.md", body) + rc, out = self.run_check(["openspec/specs/cases/screens.md"]) + self.assertEqual(rc, 1, out) + self.assertIn(f"{len(reasons)} finding(s)", out) + + def test_bare_and_short_reasons_fail(self): + self.write(self.app, "openspec/specs/cases/screens.md", "# Screens\n\n- No screen:\n- No screen: n/a\n") + rc, out = self.run_check(["openspec/specs/cases/screens.md"]) + self.assertEqual(rc, 1, out) + self.assertEqual(out.count("without a real reason"), 2, out) + + def test_real_reason_mentioning_design_passes(self): + self.write(self.app, "openspec/specs/cases/screens.md", + "# Screens\n\n- No screen: the API design for webhook delivery, no page\n") + rc, out = self.run_check(["openspec/specs/cases/screens.md"]) + self.assertEqual(rc, 0, out) + + # -- 6 --------------------------------------------------------------- + + def test_only_touched_directories_are_judged(self): + self.write(self.app, "openspec/specs/untouched/spec.md", "x\n") + self.write(self.app, "openspec/specs/cases/screens.md", CLEAN) + rc, out = self.run_check(["openspec/specs/cases/screens.md", "README.md", "lib/X.php"]) + self.assertEqual(rc, 0, out) + self.assertIn("checked 1 dir(s)", out) + + def test_a_directory_gone_from_head_is_not_judged(self): + # A change being archived leaves its old path in the diff's history. + self.write(self.app, "openspec/changes/archive/2026-10-10-add-x/screens.md", CLEAN) + rc, out = self.run_check([ + "openspec/changes/add-x/proposal.md", + "openspec/changes/archive/2026-10-10-add-x/screens.md", + ]) + self.assertEqual(rc, 0, out) + self.assertIn("checked 1 dir(s)", out) + + def test_touched_archive_directory_is_judged(self): + self.write(self.app, "openspec/changes/archive/2026-10-10-add-x/proposal.md", "x\n") + rc, out = self.run_check(["openspec/changes/archive/2026-10-10-add-x/proposal.md"]) + self.assertEqual(rc, 1, out) + self.assertIn("openspec/changes/archive/2026-10-10-add-x/: no screens.md", out) + + def test_full_tree_skips_archive(self): + self.write(self.app, "openspec/changes/archive/2026-01-01-old/proposal.md", "x\n") + self.write(self.app, "openspec/changes/live/screens.md", CLEAN) + self.write(self.app, "openspec/specs/cases/screens.md", CLEAN) + rc, out = self.run_check([], full=True) + self.assertEqual(rc, 0, out) + self.assertIn("checked 2 dir(s)", out) + + # -- 7 --------------------------------------------------------------- + + def test_unreachable_design_system_is_no_verdict(self): + self.write(self.app, "openspec/specs/cases/screens.md", CLEAN) + env = {k: v for k, v in os.environ.items() if not k.startswith("HYDRA_GATE_SCREENS")} + env["HYDRA_GATE_SCREENS_REPO_URL"] = "http://127.0.0.1:9" + proc = subprocess.run([sys.executable, CHECKER, self.app, "--changed-stdin"], + input="openspec/specs/cases/screens.md\n", capture_output=True, + text=True, timeout=120, env=env) + self.assertEqual(proc.returncode, 2, proc.stdout) + self.assertIn("UNVERIFIED", proc.stdout) + + def test_a_finding_without_network_still_fails_when_unreachable(self): + self.write(self.app, "openspec/specs/cases/screens.md", CLEAN) + self.write(self.app, "openspec/specs/other/spec.md", "x\n") + empty = os.path.join(self.root, "empty-ds") + os.makedirs(empty) + rc, out = self.run_check(["openspec/specs/cases/screens.md", "openspec/specs/other/spec.md"], + source=empty) + self.assertEqual(rc, 1, out) + self.assertIn("openspec/specs/other/: no screens.md", out) + + # -- 8 --------------------------------------------------------------- + + def test_design_backlog_marker_passes(self): + self.write(self.app, "openspec/changes/add-x/screens.md", + "# Screens\n\n- DqZaak\n- Design backlog: DqZaakTermijnen (decision 157)\n") + rc, out = self.run_check(["openspec/changes/add-x/screens.md"]) + self.assertEqual(rc, 0, out) + self.assertIn("1 design-backlog line(s), 0 finding(s)", out) + + def test_design_backlog_does_not_excuse_the_other_failures(self): + self.write(self.app, "openspec/changes/add-x/screens.md", + "# Screens\n\n- Design backlog: DqZaakTermijnen (decision 157)\n" + "- DqNope\n- No screen: not designed yet\n- No board found yet (decision 150)\n") + self.write(self.app, "openspec/specs/bare/spec.md", "x\n") + rc, out = self.run_check(["openspec/changes/add-x/screens.md", "openspec/specs/bare/spec.md"]) + self.assertEqual(rc, 1, out) + self.assertIn("4 finding(s)", out) + self.assertIn("board 'DqNope'", out) + self.assertIn("only says the design is not there yet", out) + self.assertIn("`No board found yet`", out) + self.assertIn("openspec/specs/bare/: no screens.md", out) + self.assertNotIn("DqZaakTermijnen", out) + + def test_design_backlog_naming_nothing_fails(self): + self.write(self.app, "openspec/changes/add-x/screens.md", + "# Screens\n\n- Design backlog: (decision 157)\n") + rc, out = self.run_check(["openspec/changes/add-x/screens.md"]) + self.assertEqual(rc, 1, out) + self.assertIn("without the board it proposes", out) + + # -- 9 --------------------------------------------------------------- + + def test_board_name_is_validated_not_the_url(self): + self.write(self.app, "openspec/specs/a/screens.md", + "# Screens\n\n- DqZaak https://identity.conduction.nl/screens/board?id=other/Whatever\n") + self.write(self.app, "openspec/specs/b/screens.md", + "# Screens\n\n- DqGone https://identity.conduction.nl/screens/board?id=dossiq/DqZaak\n") + rc, out = self.run_check(["openspec/specs/a/screens.md", "openspec/specs/b/screens.md"]) + self.assertEqual(rc, 1, out) + self.assertIn("1 finding(s)", out) + self.assertIn("openspec/specs/b/screens.md:3: board 'DqGone'", out) + + def test_a_school_set_board_is_found_by_its_id(self): + # screens.json keys it `wilgenboom-Artikel`; its id is `wilgenboom/Artikel`. + self.write(self.app, "openspec/specs/a/screens.md", + "# Screens\n\n- wilgenboom/Artikel https://identity.conduction.nl/screens/board?id=wilgenboom/Artikel\n") + rc, out = self.run_check(["openspec/specs/a/screens.md"]) + self.assertEqual(rc, 0, out) + + def test_a_board_file_on_main_counts_before_the_index_is_rebuilt(self): + self.write(self.ds, "screens-src/zuiddrecht/DqVers.dc.html", "\n") + self.write(self.app, "openspec/specs/a/screens.md", "# Screens\n\n- DqVers\n") + rc, out = self.run_check(["openspec/specs/a/screens.md"]) + self.assertEqual(rc, 0, out) + + # -- 10 -------------------------------------------------------------- + + def test_empty_scope_is_not_a_pass(self): + rc, out = self.run_check(["README.md"]) + self.assertEqual(rc, 3, out) + + def test_no_openspec_is_not_applicable(self): + with tempfile.TemporaryDirectory() as bare: + proc = subprocess.run([sys.executable, CHECKER, bare, "--changed-stdin"], input="", + capture_output=True, text=True, timeout=60) + self.assertEqual(proc.returncode, 4, proc.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/hydra-gates/scripts/lib/test_gate118_spec_screens_scope.sh b/hydra-gates/scripts/lib/test_gate118_spec_screens_scope.sh new file mode 100644 index 00000000..6e65ee72 --- /dev/null +++ b/hydra-gates/scripts/lib/test_gate118_spec_screens_scope.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: EUPL-1.2 +# +# gate-118 (spec-screens): acceptance over a REAL git history, through the +# real runner. +# +# A DELTA gate cannot be covered by a `gate-acceptance/` bundle: that format +# has no git history, so the gate could only report NOT APPLICABLE there. The +# rule-by-rule arms live in test_check_spec_screens.py. This suite pins what +# only the runner can get wrong: the verdict word, the scope, and the blocking. +# +# ARM 1 a touched spec with `No board found yet` FAILS, and the finding is +# printed and counted in the run's failures (no warning period) +# ARM 2 a touched change naming a real board PASSES, with the census line +# ARM 3 inherited debt: a base carrying `No board found yet` and a change +# that touches no openspec directory is NOT APPLICABLE +# ARM 4 archiving a change judges the archive directory, not the old path +# ARM 5 no delta base is NOT APPLICABLE, never PASS +# ARM 6 an unreachable design-system is SKIPPED (wiring), never PASS +# ARM 7 a touched spec on the design backlog (decision 157) PASSES +# +# The board list is a local copy (HYDRA_GATE_SCREENS_SOURCE), so no arm needs +# the network. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +RUNNER="${SCRIPT_DIR}/../run-hydra-gates.sh" + +_fail_n=0 +_ok() { printf ' ok: %s\n' "$1"; } +_bad() { _fail_n=$((_fail_n + 1)); printf ' FAIL: %s\n' "$1"; } + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/gate118-scope.XXXXXXXX")" +trap 'rm -rf "${WORK}"' EXIT +DS="${WORK}/ds" +APP="${WORK}/app" +mkdir -p "${DS}/preview/screens" "${DS}/screens-src/zuiddrecht/apps" "${APP}/appinfo" +printf '{"boards": {"FxStart": {"id": "fixture/FxStart", "app": "fixture"}}}\n' > "${DS}/preview/screens/screens.json" +printf '{"boards": {}}\n' > "${DS}/screens-src/zuiddrecht/apps/fixture.json" +export HYDRA_GATE_SCREENS_SOURCE="${DS}" + +cat > "${APP}/appinfo/info.xml" <<'XML' + + + fixture + Fixture + 1.0.0 + +XML + +_screens() { # + mkdir -p "$1" + printf '# Screens\n\n- %s\n' "$2" > "$1/screens.md" +} + +cd "${APP}" || exit 1 +git init --quiet . +git config user.email fixture@example.invalid +git config user.name Fixture + +# ── base: one spec with a real board, one change with a real board. +mkdir -p openspec/specs/cases openspec/changes/add-start +echo "# cases" > openspec/specs/cases/spec.md +_screens openspec/specs/cases "FxStart https://identity.conduction.nl/screens/board?id=fixture/FxStart" +echo "# add start" > openspec/changes/add-start/proposal.md +_screens openspec/changes/add-start "FxStart" +echo "x" > README.md +git add -A && git commit --quiet -m "base" +git branch -M base + +# ── unsettled: the change edits a spec whose screens.md is unsettled. +git checkout --quiet -b unsettled +_screens openspec/specs/cases "No board found yet (decision 150)" +echo "more" >> openspec/specs/cases/spec.md +git add -A && git commit --quiet -m "unsettled spec" + +# ── clean: the change edits the change dir, which names a real board. +git checkout --quiet base +git checkout --quiet -b clean +echo "- [x] task" > openspec/changes/add-start/tasks.md +git add -A && git commit --quiet -m "tick a task" + +# ── debt-base / unrelated: the base already carries an unsettled spec, and +# the change touches only the README. +git checkout --quiet base +git checkout --quiet -b debt-base +_screens openspec/specs/cases "No board found yet (decision 150)" +git add -A && git commit --quiet -m "inherited debt" +git checkout --quiet -b unrelated +echo "y" >> README.md +git add -A && git commit --quiet -m "an unrelated change" + +# ── archived: the change moves to archive/ without its screens.md. +git checkout --quiet base +git checkout --quiet -b archived +mkdir -p openspec/changes/archive +git mv openspec/changes/add-start openspec/changes/archive/2026-10-10-add-start +git rm --quiet -f openspec/changes/archive/2026-10-10-add-start/screens.md +git commit --quiet -m "archive add-start, losing screens.md" + +# ── backlog: the change puts the spec's UI on the design backlog. +git checkout --quiet base +git checkout --quiet -b backlog +_screens openspec/specs/cases "Design backlog: FxTermijnen (decision 157)" +echo "backlog" >> openspec/specs/cases/spec.md +git add -A && git commit --quiet -m "design backlog" + +_run() { # [extra runner args...] + local _b="$1"; shift + git checkout --quiet "${_b}" + mkdir -p "${WORK}/logs-${_b}" + HYDRA_GATE_LOG_DIR="${WORK}/logs-${_b}" bash "${RUNNER}" "$@" "${APP}" 2>&1 +} + +_verdict() { + printf '%s\n' "$1" | grep -E '^\[gate-118\] spec-screens: (PASS|FAIL|WARNING|SKIPPED|NOT APPLICABLE)' | head -1 +} + +echo "-- ARM 1: an unsettled touched spec FAILS and blocks --" +_out="$(_run unsettled --base base)" +_v="$(_verdict "${_out}")" +case "${_v}" in + *FAIL*) _ok "gate-118 reports FAIL on 'No board found yet' in a touched spec" ;; + *) _bad "expected FAIL on the unsettled branch, got: ${_v:-}" ;; +esac +if printf '%s\n' "${_out}" | grep -q 'openspec/specs/cases/screens.md:3: .No board found yet'; then + _ok "the finding names the file and line" +else + _bad "the finding is not printed with file and line" +fi +if printf '%s\n' "${_out}" | grep -qE '^\[gate-118\] spec-screens: WARNING'; then + _bad "gate-118 printed a WARNING; decision 151 makes it blocking from day one" +else + _ok "no WARNING verdict: the gate blocks" +fi + +echo "-- ARM 2: a touched change with a real board PASSES --" +_out="$(_run clean --base base)" +_v="$(_verdict "${_out}")" +case "${_v}" in + *PASS*) _ok "a change naming FxStart passes" ;; + *) _bad "expected PASS on the clean branch, got: ${_v:-}" ;; +esac +case "${_out}" in + *"[gate-118] spec-screens: checked 1 dir(s), 1 board line(s)"*) _ok "the census line shows it read the change directory" ;; + *) _bad "no census line for the clean branch" ;; +esac + +echo "-- ARM 3: inherited debt is not this change's problem --" +_v="$(_verdict "$(_run unrelated --base debt-base)")" +case "${_v}" in + *"NOT APPLICABLE"*) _ok "a README-only change is not judged, though the base carries an unsettled spec" ;; + *) _bad "expected NOT APPLICABLE on a README-only change, got: ${_v:-}" ;; +esac + +echo "-- ARM 4: archiving judges the archive directory --" +_out="$(_run archived --base base)" +_v="$(_verdict "${_out}")" +case "${_v}" in + *FAIL*) _ok "an archived change without screens.md fails" ;; + *) _bad "expected FAIL on the archived branch, got: ${_v:-}" ;; +esac +if printf '%s\n' "${_out}" | grep -q 'openspec/changes/archive/2026-10-10-add-start/: no screens.md'; then + _ok "the finding names the archive path, not the old one" +else + _bad "the archive directory is not named" +fi + +echo "-- ARM 5: no delta base is NOT APPLICABLE, never PASS --" +_v="$(_verdict "$(_run unsettled --full)")" +case "${_v}" in + *"NOT APPLICABLE"*) _ok "with no base the gate says it had nothing to judge" ;; + *) _bad "expected NOT APPLICABLE with no base, got: ${_v:-}" ;; +esac + +echo "-- ARM 6: an unreachable design-system is SKIPPED, never PASS --" +_v="$(_verdict "$(HYDRA_GATE_SCREENS_SOURCE='' HYDRA_GATE_SCREENS_REPO_URL='http://127.0.0.1:9' _run clean --base base)")" +case "${_v}" in + *"SKIPPED (wiring)"*) _ok "board lines that could not be checked are not a pass" ;; + *) _bad "expected SKIPPED (wiring) with design-system unreachable, got: ${_v:-}" ;; +esac + +echo "-- ARM 7: the design backlog marker PASSES --" +_v="$(_verdict "$(_run backlog --base base)")" +case "${_v}" in + *PASS*) _ok "a touched spec on the design backlog passes" ;; + *) _bad "expected PASS on the backlog branch, got: ${_v:-}" ;; +esac + +echo "" +if [ "${_fail_n}" -eq 0 ]; then + echo "gate-118 spec-screens acceptance: all arms passed." + exit 0 +fi +echo "gate-118 spec-screens acceptance: ${_fail_n} arm(s) failed." +exit 1 diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index f1bda9b4..7e8793c7 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -13355,6 +13355,108 @@ else _skip 116 "connections-declaration" na "this app ships no lib/Settings/connections.json, so it declares no connections to check." fi +# --------------------------------------------------------------------------- +# GATE 118 · spec-screens +# +# Screens first (hydra ADR "screens first", decisions 150 to 152). The drawn +# boards in ConductionNL/design-system, published at +# identity.conduction.nl/screens, are the UI canon. Every OpenSpec change and +# spec says which boards it builds, in a `screens.md` next to it: +# +# # Screens +# +# - DqZaak https://identity.conduction.nl/screens/board?id=dossiq/DqZaak +# - No screen: a nightly job that mails reminders; no page changes +# +# For each openspec/changes//, openspec/changes/archive// and +# openspec/specs// directory this change touches: screens.md exists; every +# `- ` line names a board on design-system main; a `- No screen:` reason +# is real (design-system's own PLACEHOLDER_REASON list decides what only says +# "not designed yet"); and `- No board found yet`, the line the generator +# writes where it found nothing, fails. A directory a PR touches has to settle +# it, by naming the board, drawing one in a paired design-system PR, putting it +# on the design backlog, or giving a real reason. +# +# `- Design backlog: (decision 157)` PASSES. It marks real UI +# whose board is not drawn yet and names the board it proposes. Board names are +# validated, not the URL text after them. +# +# BLOCKING FROM DAY ONE, deliberately, against the usual warning-first rule +# for new gates (decision 151). The gate lands after every app's generated +# screens.md has landed, and it judges only directories the change touches, so +# a red is this PR's own work. There is no _BLOCKING opt-in to set. +# +# A DELTA GATE, keyed on HAVE_DELTA_BASE as gates 101, 108 and 116 are. With +# no base it is NOT APPLICABLE, never PASS. When the base is the empty tree (a +# push whose previous tip is unknown, so every file is in scope) it audits the +# live changes and specs and leaves archive/ alone: an archived change written +# before screens.md existed is history, not a delivery. +# +# NETWORK. The board list is read from design-system main over HTTPS, once +# per run (HYDRA_GATE_SCREENS_SOURCE points it at a local copy). Unreachable +# is not a finding: board lines go unverified and the gate reports no verdict, +# unless a finding that needs no network already fails it. +# +# See scripts/lib/check_spec_screens.py for the rules and why the published +# index, not only apps/.json, decides whether a board exists. +# --------------------------------------------------------------------------- +if [ -d openspec/changes ] || [ -d openspec/specs ]; then + _scr_log=${HYDRA_GATE_LOG_DIR}/hydra-gate-spec-screens.log + : > "${_scr_log}" + _scr_helper="${SCRIPT_DIR}/lib/check_spec_screens.py" + if [ ! -f "${_scr_helper}" ]; then + _skip 118 "spec-screens" wiring "check_spec_screens.py not found at ${_scr_helper}. This repo has OpenSpec changes or specs and NO screens.md was checked, so whether they name their boards is UNVERIFIED by this run." + elif [ "${HAVE_DELTA_BASE}" != "1" ]; then + _skip 118 "spec-screens" na "no delta base was resolved, so there is no changed-file set. This gate judges the openspec change and spec directories a change touches. With no base it has nothing to judge, and saying so is not a pass. Give it a base with --base or HYDRA_GATE_BASE_REF." + else + set +e + if [ -n "${_empty_tree:-}" ] && [ "${BASE_REF}" = "${_empty_tree}" ]; then + python3 "${_scr_helper}" . --full-tree > "${_scr_log}" 2>&1 + else + printf '%s\n' "${CHANGED_FILES}" \ + | python3 "${_scr_helper}" . --changed-stdin > "${_scr_log}" 2>&1 + fi + _scr_rc=$? + set +e + # The census line prints on every verdict, so a PASS can be shown to + # have read the directories. + grep -E '^\[gate-118\] spec-screens: checked ' "${_scr_log}" 2>/dev/null | tail -1 + case "${_scr_rc}" in + 0) + if _helper_finished "${_scr_log}" '^\[gate-118\] spec-screens: checked [0-9]+ dir'; then + _pass 118 "spec-screens" + else + _skip 118 "spec-screens" wiring "check_spec_screens.py exited 0 without its census line, so it did not finish and screens.md is UNVERIFIED by this run. See ${_scr_log}." + fi + ;; + 1) + if _helper_finished "${_scr_log}" '^\[gate-118\] spec-screens: FAIL: [0-9]+ finding'; then + grep -E '^ ' "${_scr_log}" 2>/dev/null + _scr_n=$(sed -n 's/^\[gate-118\] spec-screens: FAIL: \([0-9]*\) finding.*/\1/p' "${_scr_log}" | tail -1) + _fail 118 "spec-screens" "${_scr_n:-1} finding(s) in the screens.md of the openspec directories this change touches. Name the board, draw one in a paired design-system PR, or give a real no-screen reason (hydra ADR screens first). See ${_scr_log}" + else + # A CRASH IS NOT A FINDING. A Python traceback also exits 1. + _scr_why=$(tail -3 "${_scr_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200) + _skip 118 "spec-screens" wiring "check_spec_screens.py exited 1 without its FAIL summary, so it did not finish and screens.md is UNVERIFIED by this run. Checker output: ${_scr_why:-}. See ${_scr_log}." + fi + ;; + 3) + _skip 118 "spec-screens" na "this change touches no openspec/changes// or openspec/specs// directory, so it introduces no change or spec whose screens could be missing. See ${_scr_log}." + ;; + 4) + _skip 118 "spec-screens" na "no openspec/changes or openspec/specs here, so there is no change or spec to name its screens." + ;; + *) + grep -E '^ ' "${_scr_log}" 2>/dev/null + _scr_why=$(tail -3 "${_scr_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200) + _skip 118 "spec-screens" wiring "check_spec_screens.py exited ${_scr_rc} without a verdict (design-system unreachable, or the checker failed), so the board lines are UNVERIFIED by this run. Checker output: ${_scr_why:-}. See ${_scr_log}." + ;; + esac + fi +else + _skip 118 "spec-screens" na "no openspec/changes or openspec/specs here, so there is no change or spec to name its screens." +fi + # --------------------------------------------------------------------------- # Summary + COVERAGE ACCOUNTING # diff --git a/hydra-gates/scripts/test-fixtures/gate-acceptance/COVERED-ELSEWHERE.md b/hydra-gates/scripts/test-fixtures/gate-acceptance/COVERED-ELSEWHERE.md index b110e6a0..345df949 100644 --- a/hydra-gates/scripts/test-fixtures/gate-acceptance/COVERED-ELSEWHERE.md +++ b/hydra-gates/scripts/test-fixtures/gate-acceptance/COVERED-ELSEWHERE.md @@ -54,6 +54,7 @@ with the missing half named. | gate-110 | migration-version-bump | `test_gate110_migration_version_bump_scope.sh` | builds a real two-commit history in a tmpdir — a delta gate cannot be covered by a bundle, and this one is a delta gate twice over: it needs the base to know what the change ADDED **and** to read `` as it stood before. ARM 1's planted arm carries TWO independently fatal defects in the TWO shapes Nextcloud gates on version — a `VersionDate` migration and a ``-registered repair step — each asserted BY NAME in the findings log, plus the base version it compared against. ARM 5 removes each defect on its own and asserts the arm STILL fails, so a checker that had learned to see only migrations (openregister#3451's original shape, and NOT the shape that stranded dossiq's `RealignStatutoryVocabulary`) cannot pass ARM 1 for free. ARM 2 is the anti-widening pair: the same two additions WITH a bump must PASS. ARM 3 pins that a commit touching neither directory reports NOT APPLICABLE — eight fleet apps carried stranded steps the day this landed, and a full-tree version would have reddened every branch they cut. ARM 4 keeps three near misses green with NO bump: a trait, an unregistered helper class, and an ``-only step, none of which Nextcloud runs on upgrade. ARM 6 pins that a timestamp-only move IS a bump, because PHP's `version_compare` says so and that is the comparison the server makes — a gate refusing it would fail every release-bot build. ARM 7 pins that a run with no delta base SKIPS and never PASSES. Mutation-proved red: dropping `lib/Repair` from the watched paths (3 arms), accepting an unmoved version (5 arms), treating every added `.php` as a subject (1 arm), and turning the no-base branch into a `_pass` (ARM 7). The comparator itself is additionally fixtured by `scripts/lib/test_check_migration_version_bump.py`, which differential-tests the PHP `version_compare` port against a real `php` when one is on PATH — 36,100 pairs — and holds the checker to exit 3, never 0, on a base it cannot resolve. | | gate-114 | header-action-budget | `test_gate114_header_action_budget_scope.sh` | builds a real two-commit history in a tmpdir. A delta gate cannot be covered by a bundle: the ratchet asks whether a page's header actions bar is LONGER than at the base, and the generic format has no git. ARM 1 plants a sixth button on a page whose base carried five and asserts the WARNING names the page and both counts, plus the `base=5 head=6 delta=+1` line on stdout. ARM 2 is the warning-first pin: the run's own failure count equals its FAIL lines, so the advisory reached neither, and gate-114 never printed a FAIL verdict. ARM 3 pins that a commit touching no manifest reports nothing, though the base already carries a five-button bar. ARM 4 is the anti-widening arm: a page that did not exist at the base is censused (`max=7 on BezwaarDetail`) and NOT ratcheted. ARM 5 pins that a removal is clean and that the negative delta is still printed, so a burn-down can be evidenced. ARM 6 is the load-bearing one: with no delta base the ratchet cannot run, and the run must NAME the half that did not run rather than printing a bare PASS. That is the exact defect gates 52, 68 and 69 carry, measured on dossiq PR #1985 where gate-69 printed PASS over a custom-page increase with its delta base resolved. ARM 7 pins the per-repo promotion path, `HYDRA_GATE_HEADER_ACTION_BUDGET_BLOCKING=1`. | | gate-116 | connections-declaration | `test_gate116_connections_declaration_scope.sh` | builds a real git history in a tmpdir. A delta gate cannot be covered by a bundle, which has no git. ARM 1 plants a duplicate connection key and asserts the WARNING and a log line naming the key and both positions. ARM 2 is the warning-first pin: no FAIL verdict, and the planted branch carries as many FAIL lines as the clean branch. ARM 3 pins inherited debt: a base already carrying a mismatched app id, and a README-only change, is NOT APPLICABLE. ARM 4 is the two-sided rule: renaming the settings section under src/ with the declaration untouched still warns and names `#section-zgw`. ARM 5 is the clean arm, PASS plus the census line on stdout. ARM 6 pins that no delta base is NOT APPLICABLE, never PASS. ARM 7 pins the per-repo promotion, `HYDRA_GATE_CONNECTIONS_DECLARATION_BLOCKING=1`. ARM 8 pins that an app with no declaration is NOT APPLICABLE. The rule-by-rule planted and near-miss arms (schema, app id, duplicate key, anchor defined versus only linked, `section-zgw-extra`, node_modules, invalid JSON, missing Ajv, unsourced schema) are in `test_check_connections_declaration.js`. | +| gate-118 | spec-screens | `test_gate118_spec_screens_scope.sh` | builds a real git history in a tmpdir with a local copy of the design-system layout (`HYDRA_GATE_SCREENS_SOURCE`), so no arm needs the network. ARM 1 plants `No board found yet` in a touched spec and asserts FAIL, the file and line in the output, and no WARNING (blocking from day one, decision 151). ARM 2 is the clean arm: a touched change naming a real board PASSES with the census line. ARM 3 pins inherited debt: a base carrying an unsettled spec and a README-only change is NOT APPLICABLE. ARM 4 archives a change without its screens.md and asserts the finding names the archive path. ARM 5 pins that no delta base is NOT APPLICABLE, never PASS. ARM 6 points the board list at an unreachable host and asserts SKIPPED (wiring), never PASS. ARM 7 puts a touched spec on the design backlog (`- Design backlog: (decision 157)`) and asserts PASS. The rule-by-rule arms (the backlog marker beside the failures it does not excuse, board names validated rather than URL text, index and app-file boards, cross-app boards, every placeholder reason, bare and short reasons, scope, archive under --full-tree, no verdict without the board list) are in `test_check_spec_screens.py`. | | gate-100 | setup-demo-data-first | `test_gate100_setup_demo_first_scope.sh` | real two-commit history. ARM 3 pins that an app declaring NO setup block is not failed — twenty of thirty fleet manifests are in that state, so failing them would block unrelated manifest edits fleet-wide. Adoption is a rollout; the gate holds the line on the ten that exist. | | gate-19 | e2e-coverage | `test_gate19_coverage_credibility.sh` | `test-fixtures/e2e-credibility/{req-inherit,scenario-level,file-level-tag,honest}` — `.github#356`, `#343`, `#345` and the never-false `test.skip` guard | | gate-23 | or-abstraction-anti-patterns | `test_gates_23_33_never_green_over_nothing.sh` | `test-fixtures/gates-23-33/{planted,clean}` |