diff --git a/hydra-gates/scripts/lib/check_procedure_code.py b/hydra-gates/scripts/lib/check_procedure_code.py new file mode 100755 index 00000000..6ef58cd1 --- /dev/null +++ b/hydra-gates/scripts/lib/check_procedure_code.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Conduction +# SPDX-License-Identifier: EUPL-1.2 +"""gate-119 helper: procedure-code ratchet (decision 182). + +Procedures are configuration, code is generic. An app that grows a +`BezwaarService` or a `WooPublicationController` is hard-coding a Dutch +procedure that should be a schema, a workflow or a template in configuration. +This gate counts the files that do it and holds the count to a committed +baseline, the way the custom-widget ratchet holds widgets. + +WHAT COUNTS +----------- +PHP, JS, TS and Vue files under lib/ and src/ whose repo-relative path, or a +class/interface/trait/enum declared inside, contains a procedure token. The +token lists and the national-standard allowlist live in ONE file, +procedure_code_tokens.json next to this script. See its $comment. + +THE RATCHET +----------- +The baseline is `.procedure-code-baseline.json` in the app root: {"count": N}. + + * no baseline file -> NOT APPLICABLE (exit 4). Never PASS. + * count > baseline -> FAIL: the change added procedure-named code. + * count < baseline -> FAIL: the change removed some, so it must + lower the baseline in the same PR. The + message gives the number to write. + * count == baseline -> PASS. + * baseline raised by the change (needs --base) -> FAIL. A ratchet whose + ceiling can be lifted in the same PR that exceeds it is not a ratchet. + * baseline unreadable -> FAIL, naming the file. + +Usage: + check_procedure_code.py [--base ] [--ref ] [--config ] + [--list] [--json] + + --ref count the tree of a git ref (git ls-tree + git grep) instead of the + working tree. Read-only. This is how the fleet baselines were + measured, and it agrees with the working-tree mode on a clean + checkout of the same commit. + --base the ref the change started from, to compare baselines. + --list print every counted file. + +Output always ends in one of + [gate-119] procedure-code: counted N file(s), baseline B + [gate-119] procedure-code: FAIL: K finding(s) +and exit status is a boolean: 0 clean, 1 findings, 4 no baseline (not +applicable), 2 the checker itself could not run. A traceback is exit 1 with +no summary line, which the runner reports as WIRING, never as a finding +(the gate-29 lesson: a count in the exit byte cannot tell a crash from a +finding). +""" + +import argparse +import json +import os +import re +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_CONFIG = os.path.join(HERE, "procedure_code_tokens.json") +BASELINE_NAME = ".procedure-code-baseline.json" + +DECL_RE = re.compile( + r"^\s*(?:export\s+(?:default\s+)?)?(?:(?:abstract|final|readonly)\s+)*" + r"(?:class|interface|trait|enum)\s+([A-Za-z_][A-Za-z0-9_]*)", + re.MULTILINE, +) +WORD_RE = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+") + + +def load_config(path): + with open(path, encoding="utf-8") as fh: + cfg = json.load(fh) + for key in ("roots", "extensions", "substringTokens", "wordTokens", "allow"): + if key not in cfg: + raise ValueError("config %s lacks %r" % (path, key)) + cfg["substringTokens"] = [t.lower() for t in cfg["substringTokens"]] + cfg["wordTokens"] = [t.lower() for t in cfg["wordTokens"]] + cfg["allow"]["words"] = [w.lower() for w in cfg["allow"].get("words", [])] + cfg["allow"]["pathPrefixes"] = [p.lower().rstrip("/") for p in cfg["allow"].get("pathPrefixes", [])] + return cfg + + +def words_of(text): + """Split a path or class name into lowercase words.""" + out = [] + for chunk in re.split(r"[^A-Za-z0-9]+", text): + out.extend(w.lower() for w in WORD_RE.findall(chunk)) + return out + + +def _has_run(words, run): + n = len(run) + return any(words[i:i + n] == run for i in range(len(words) - n + 1)) + + +def classify(path, class_names, cfg): + """Return (counted, matched_tokens, excused_by). excused_by is '' unless excluded.""" + haystacks = [path] + list(class_names) + low = [h.lower() for h in haystacks] + wordlists = [words_of(h) for h in haystacks] + hits = [] + for tok in cfg["substringTokens"]: + if any(tok in h for h in low): + hits.append(tok) + for tok in cfg["wordTokens"]: + if any(tok in ws for ws in wordlists): + hits.append(tok) + if not hits: + return False, [], "" + lp = path.lower() + for pref in cfg["allow"]["pathPrefixes"]: + if lp == pref or lp.startswith(pref + "/"): + return False, hits, "path:" + pref + for aw in cfg["allow"]["words"]: + run = aw.split("-") + if any(_has_run(ws, run) for ws in wordlists): + return False, hits, "word:" + aw + return True, hits, "" + + +def _in_scope(path, cfg): + parts = path.split("/") + if parts[0] not in cfg["roots"]: + return False + if any(p in cfg.get("ignoreDirs", []) for p in parts[:-1]): + return False + return path.rsplit(".", 1)[-1].lower() in cfg["extensions"] and "." in parts[-1] + + +def _git(app, *args): + return subprocess.run(["git", "-c", "safe.directory=*", "-C", app] + list(args), + capture_output=True, text=True, errors="replace") + + +def collect_working(app, cfg): + """path -> [class names], for the working tree (tracked files when in git).""" + r = _git(app, "ls-files", "-z", "--", *cfg["roots"]) + if r.returncode == 0 and r.stdout: + paths = [p for p in r.stdout.split("\0") if p] + else: + paths = [] + for root in cfg["roots"]: + for dp, dn, fn in os.walk(os.path.join(app, root)): + dn[:] = [d for d in dn if d not in cfg.get("ignoreDirs", [])] + for f in fn: + paths.append(os.path.relpath(os.path.join(dp, f), app).replace(os.sep, "/")) + found = {} + for p in sorted(set(paths)): + if not _in_scope(p, cfg) or not os.path.isfile(os.path.join(app, p)): + continue + try: + with open(os.path.join(app, p), encoding="utf-8", errors="replace") as fh: + found[p] = DECL_RE.findall(fh.read()) + except OSError: + found[p] = [] + return found + + +def collect_ref(app, ref, cfg): + r = _git(app, "ls-tree", "-r", "--name-only", "-z", ref, "--", *cfg["roots"]) + if r.returncode != 0: + raise RuntimeError("git ls-tree %s failed: %s" % (ref, r.stderr.strip())) + found = {p: [] for p in r.stdout.split("\0") if p and _in_scope(p, cfg)} + g = _git(app, "grep", "-I", "-n", "--no-color", "-E", + r"^\s*(export\s+(default\s+)?)?((abstract|final|readonly)\s+)*(class|interface|trait|enum)\s+[A-Za-z_]", + ref, "--", *cfg["roots"]) + if g.returncode not in (0, 1): + raise RuntimeError("git grep %s failed: %s" % (ref, g.stderr.strip())) + prefix = ref + ":" + for line in g.stdout.splitlines(): + if not line.startswith(prefix): + continue + rest = line[len(prefix):] + path, _, tail = rest.partition(":") + _, _, text = tail.partition(":") + if path in found: + found[path].extend(DECL_RE.findall(text)) + return found + + +def read_baseline(app, ref=None): + """Return (state, value): ('missing', None) | ('ok', int) | ('bad', reason).""" + if ref: + r = _git(app, "show", "%s:%s" % (ref, BASELINE_NAME)) + if r.returncode != 0: + return "missing", None + raw = r.stdout + else: + p = os.path.join(app, BASELINE_NAME) + if not os.path.isfile(p): + return "missing", None + with open(p, encoding="utf-8") as fh: + raw = fh.read() + try: + data = json.loads(raw) + n = data["count"] + if isinstance(n, bool) or not isinstance(n, int) or n < 0: + raise ValueError("count must be a non-negative integer") + return "ok", n + except (ValueError, KeyError, TypeError) as exc: + return "bad", "%s is not a valid baseline (%s)" % (BASELINE_NAME, exc) + + +def count_tree(app, cfg, ref=None): + files = collect_ref(app, ref, cfg) if ref else collect_working(app, cfg) + counted, excused = [], [] + for path, names in sorted(files.items()): + ok, hits, why = classify(path, names, cfg) + if ok: + counted.append((path, hits)) + elif why: + excused.append((path, why)) + return counted, excused + + +def main(argv): + ap = argparse.ArgumentParser() + ap.add_argument("app") + ap.add_argument("--base") + ap.add_argument("--ref") + ap.add_argument("--config", default=os.environ.get("HYDRA_GATE_PROCEDURE_CONFIG") or DEFAULT_CONFIG) + ap.add_argument("--list", action="store_true") + ap.add_argument("--json", action="store_true") + a = ap.parse_args(argv) + try: + cfg = load_config(a.config) + counted, excused = count_tree(a.app, cfg, a.ref) + except (OSError, ValueError, RuntimeError) as exc: + print("[gate-119] procedure-code: ERROR: %s" % exc) + return 2 + n = len(counted) + if a.json: + print(json.dumps({"count": n, "excluded": len(excused)})) + return 0 + if a.list: + for path, hits in counted: + print(" counted %s [%s]" % (path, ",".join(hits))) + for path, why in excused: + print(" allowlisted %s (%s)" % (path, why)) + state, base_n = read_baseline(a.app, a.ref) + if state == "missing": + print("[gate-119] procedure-code: counted %d file(s), no %s" % (n, BASELINE_NAME)) + return 4 + findings = [] + if state == "bad": + findings.append(" %s" % base_n) + else: + if n > base_n: + findings.append(" %d procedure-named file(s), baseline %d: the change added %d. Procedures are configuration " + "(decision 182); put it in a schema, workflow or template. Counted files: see --list." % (n, base_n, n - base_n)) + elif n < base_n: + findings.append(" %d procedure-named file(s), baseline %d: the change removed %d, so lower %s to " + '{"count": %d} in this same PR.' % (n, base_n, base_n - n, BASELINE_NAME, n)) + if a.base: + bstate, bn = read_baseline(a.app, a.base) + if bstate == "ok" and base_n > bn: + findings.append(" %s was raised from %d to %d. The baseline only goes down." % (BASELINE_NAME, bn, base_n)) + print("[gate-119] procedure-code: counted %d file(s), baseline %s, %d allowlisted" % + (n, base_n if state == "ok" else "unreadable", len(excused))) + if findings: + for f in findings: + print(f) + print("[gate-119] procedure-code: FAIL: %d finding(s)" % len(findings)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/hydra-gates/scripts/lib/procedure_code_tokens.json b/hydra-gates/scripts/lib/procedure_code_tokens.json new file mode 100644 index 00000000..9d8b9a4f --- /dev/null +++ b/hydra-gates/scripts/lib/procedure_code_tokens.json @@ -0,0 +1,60 @@ +{ + "$comment": [ + "gate-119 (procedure-code), decision 182: procedures are configuration, code is generic.", + "THE ONE FILE that says what counts. Edit the lists here, not in the checker.", + "", + "A file COUNTS when its path, or a class/interface/trait/enum it declares, names a Dutch procedure.", + " substringTokens matched anywhere in the lowercased path or class name (long tokens that no other word contains).", + " wordTokens matched only as a whole word after camelCase/snake/kebab splitting. Short tokens live here,", + " so 'woo' does not match 'Wood' and 'dso' does not match a word that merely contains it.", + "", + "A counted file is EXCLUDED when it is a national-standard adapter:", + " allow.words whole-word (or hyphenated word run, 'dso-adapter') in the path or class name.", + " allow.pathPrefixes repo-relative prefixes, case-insensitive. This is how an ambiguous token such as 'dso' is", + " excused: by WHERE the file lives, not by the token alone.", + "", + "allow.pathPrefixes also lists exact files of integriq's DSO-LV adapter (STAM koppelvlak, PKIoverheid signatures, DSO-SWF, status push, DSO exceptions and PKI admin). Exact files, because they are not under one directory. They match case-insensitively in every app, so check another app does not share a path before adding one. The DSO activity-to-zaaktype mapping and samenloop logic is NOT excused: it is procedure logic and stays counted (decision 188).", + "", + "Which source files are read:", + " roots / extensions" + ], + "roots": ["lib", "src"], + "extensions": ["php", "js", "ts", "mjs", "vue"], + "substringTokens": [ + "bezwaar", "beschikking", "subsid", "termijn", "besluit", "leerplicht", + "verlof", "melding", "klacht", "jeugd", "bijstand", "omgevings" + ], + "wordTokens": ["woo", "vth", "dso", "wmo", "leges"], + "allow": { + "words": ["zgw", "ztc", "zrc", "drc", "stuf", "brp", "kvk", "dso-adapter"], + "pathPrefixes": [ + "lib/Adapter", "lib/Service/Zgw", + "lib/BackgroundJob/FetchDsoAttachmentsJob.php", + "lib/Controller/DsoPkiSettingsController.php", + "lib/EventListener/DsoStamConsumerListener.php", + "lib/Exception/DsoAttachmentTooLargeException.php", + "lib/Exception/DsoConnectionUnavailableException.php", + "lib/Exception/DsoProviderException.php", + "lib/Exception/DsoSignatureException.php", + "lib/Exception/DsoTranslationException.php", + "lib/Notification/DsoConnectionNotifier.php", + "lib/Repair/MigrateDsoStamConnection.php", + "lib/Service/DSOParserService.php", + "lib/Service/DSOSamenwerkingService.php", + "lib/Service/DSOSignatureVerifierService.php", + "lib/Service/DSOStatusService.php", + "lib/Service/Dso/DsoAccountRights.php", + "lib/Service/Dso/DsoAttachmentFetcher.php", + "lib/Service/Dso/DsoClient.php", + "lib/Service/Dso/DsoConnection.php", + "lib/Service/Dso/DsoConnectionAlerts.php", + "lib/Service/Dso/DsoConnectorProviderInterface.php", + "lib/Service/Dso/DsoIdentity.php", + "lib/Service/Dso/DsoRequestTranslator.php", + "lib/Service/Dso/LogDsoConnectorProvider.php", + "lib/Service/DsoIngestService.php", + "src/views/admin/DsoPkiSettings.vue" + ] + }, + "ignoreDirs": ["node_modules", "vendor", "dist", "build", "coverage", ".git"] +} diff --git a/hydra-gates/scripts/lib/test_check_procedure_code.py b/hydra-gates/scripts/lib/test_check_procedure_code.py new file mode 100755 index 00000000..0a29194b --- /dev/null +++ b/hydra-gates/scripts/lib/test_check_procedure_code.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Conduction +# SPDX-License-Identifier: EUPL-1.2 +"""Tests for gate-119's checker (check_procedure_code.py), rule by rule. + + 1. A PROCEDURE TOKEN IN THE PATH COUNTS, for each token in the config. + 2. A PROCEDURE TOKEN IN A DECLARED CLASS NAME COUNTS, though the path is bland. + 3. SHORT TOKENS MATCH WHOLE WORDS ONLY. 'woo' is not 'Wood', 'dso' is not a + word that merely contains it; camelCase, acronyms and kebab-case split. + 4. A NATIONAL-STANDARD ADAPTER IS EXCLUDED by word (zgw, stuf, dso-adapter) ... + 5. ... AND AN AMBIGUOUS TOKEN IS EXCUSED BY PATH: 'dso' under lib/Adapter is + excluded, the same word elsewhere is counted. + 6. ONLY lib/ AND src/, ONLY php/js/ts/vue count. tests/ and .md do not. + 7. THE RATCHET: no baseline is exit 4 (never 0), equal is 0, above FAILS, + below FAILS with the number to write, a raised baseline FAILS, a broken + baseline FAILS. + 8. THE CONFIG IS THE ONLY LIST: a custom config changes what counts. + 9. REF MODE AGREES WITH WORKING-TREE MODE on a committed tree. + +Run: python3 scripts/lib/test_check_procedure_code.py +""" +import atexit +import contextlib +import io +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import check_procedure_code as cpc # noqa: E402 + +CFG = cpc.load_config(cpc.DEFAULT_CONFIG) + + +def cls(path, names=()): + return cpc.classify(path, list(names), CFG) + + +def run_main(argv): + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = cpc.main(argv) + return rc, out.getvalue() + + +def git(d, *a): + subprocess.run(["git", "-C", d, "-c", "user.email=f@x.invalid", "-c", "user.name=F", *a], + check=True, capture_output=True) + + +def make_app(files, baseline=None): + d = tempfile.mkdtemp(prefix="gate119-") + atexit.register(shutil.rmtree, d, True) + git(d, "init", "-q") + for p, body in files.items(): + os.makedirs(os.path.dirname(os.path.join(d, p)) or d, exist_ok=True) + with open(os.path.join(d, p), "w") as fh: + fh.write(body) + if baseline is not None: + with open(os.path.join(d, cpc.BASELINE_NAME), "w") as fh: + fh.write(baseline if isinstance(baseline, str) else json.dumps({"count": baseline})) + git(d, "add", "-A") + git(d, "commit", "-q", "-m", "base") + return d + + +class Rules(unittest.TestCase): + def test_1_every_token_counts_in_a_path(self): + for tok in CFG["substringTokens"] + CFG["wordTokens"]: + ok, hits, _ = cls("lib/Service/%sService.php" % tok.capitalize()) + self.assertTrue(ok, tok) + self.assertIn(tok, hits) + + def test_1b_the_assigned_tokens_are_all_configured(self): + want = ("woo bezwaar beschikking vth dso subsid termijn besluit leerplicht verlof melding klacht jeugd " + "wmo bijstand leges omgevings").split() + have = set(CFG["substringTokens"] + CFG["wordTokens"]) + self.assertEqual(set(want), have) + + def test_2_class_name_counts(self): + ok, hits, _ = cls("lib/Service/Handler.php", ["BezwaarHandler"]) + self.assertTrue(ok) + self.assertEqual(hits, ["bezwaar"]) + + def test_2b_a_bland_file_does_not_count(self): + self.assertFalse(cls("lib/Service/ObjectService.php", ["ObjectService"])[0]) + + def test_3_short_tokens_are_whole_words(self): + self.assertFalse(cls("lib/Service/WoodService.php")[0]) + self.assertFalse(cls("src/views/Dsonar.vue")[0]) + self.assertTrue(cls("lib/Service/WooPublication.php")[0]) + self.assertTrue(cls("src/views/woo-publication.vue")[0]) + self.assertTrue(cls("lib/Service/WOOPublication.php")[0]) + self.assertTrue(cls("lib/Service/Foo/WMO_Aanvraag.php")[0]) + + def test_4_adapter_words_excluded(self): + for path in ("lib/Service/ZgwBesluitMapper.php", "lib/Service/BeschikkingStufExport.php", + "lib/Service/DsoAdapter.php", "src/api/kvk-melding.js"): + ok, hits, why = cls(path) + self.assertFalse(ok, path) + self.assertTrue(hits and why, path) + + def test_4b_dso_adapter_is_a_phrase_not_two_words(self): + # 'dso' alone is ambiguous and counts; 'dso-adapter' as a phrase is excused. + self.assertTrue(cls("lib/Service/DsoMelding.php")[0]) + self.assertFalse(cls("lib/Service/Dso/Adapter.php")[0]) + + def test_5_ambiguous_token_is_excused_by_path(self): + self.assertFalse(cls("lib/Adapter/DsoClient.php")[0]) + self.assertFalse(cls("lib/Service/Zgw/BesluitService.php")[0]) + self.assertTrue(cls("lib/Service/DsoClient.php")[0]) + self.assertTrue(cls("lib/Adapters/DsoClient.php")[0], "a sibling name is not the prefix") + + def test_6_scope(self): + d = make_app({ + "lib/BezwaarService.php": "", + "tests/BezwaarTest.php": "", + "src/store/s.js": "export default class VerlofStore {}\n", + "lib/Service/Zgw/Besluit.php": " "${APP}/appinfo/info.xml" <<'XML' + + + fixture + Fixture + 1.0.0 + +XML + +cd "${APP}" || exit 1 +git init --quiet . +git config user.email fixture@example.invalid +git config user.name Fixture + +# ── base: two procedure files, one generic file, NO baseline yet. +printf ' lib/Service/BezwaarService.php +printf ' lib/Service/WooService.php +printf ' lib/Service/ObjectService.php +echo "x" > README.md +git add -A && git commit --quiet -m "base" +git branch -M base + +# ── nobaseline: an unrelated change, no baseline anywhere. +git checkout --quiet -b nobaseline +echo "y" >> README.md +git commit --quiet -am "unrelated" + +# ── adopted: base carries a baseline of 2. +git checkout --quiet base +git checkout --quiet -b adopted-base +echo '{"count": 2}' > .procedure-code-baseline.json +git add -A && git commit --quiet -m "adopt baseline" +git branch -M adopted + +_branch() { git checkout --quiet adopted && git checkout --quiet -b "$1"; } + +_branch equal +echo "y" >> README.md +git commit --quiet -am "unrelated" + +_branch grew +printf ' lib/Service/BeschikkingService.php +git add -A && git commit --quiet -m "add a procedure class" + +_branch shrank-stale +git rm --quiet lib/Service/WooService.php +git commit --quiet -m "remove WooService, forget the baseline" + +_branch shrank-lowered +git rm --quiet lib/Service/WooService.php +echo '{"count": 1}' > .procedure-code-baseline.json +git add -A && git commit --quiet -m "remove WooService, lower the baseline" + +_branch lifted +printf ' lib/Service/BeschikkingService.php +echo '{"count": 3}' > .procedure-code-baseline.json +git add -A && git commit --quiet -m "add a class and lift the ceiling" + +_branch adapter +mkdir -p lib/Service/Zgw +printf ' lib/Service/Zgw/BesluitMapper.php +printf ' lib/Service/StufBeschikking.php +git add -A && git commit --quiet -m "add national-standard adapters" + +_branch badjson +echo '{not json' > .procedure-code-baseline.json +git commit --quiet -am "break the baseline" + +_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-119\] procedure-code: (PASS|FAIL|WARNING|SKIPPED|NOT APPLICABLE)' | head -1 +} + +echo "-- ARM 1: no baseline file is NOT APPLICABLE, never PASS --" +_v="$(_verdict "$(_run nobaseline --base base)")" +case "${_v}" in + *"NOT APPLICABLE"*) _ok "no baseline: the gate says it compared nothing" ;; + *) _bad "expected NOT APPLICABLE without a baseline, got: ${_v:-}" ;; +esac +case "${_v}" in + *PASS*) _bad "PASS without a baseline" ;; + *) _ok "not a PASS" ;; +esac + +echo "-- ARM 2: count equals the baseline PASSES --" +_out="$(_run equal --base adopted)" +case "$(_verdict "${_out}")" in + *PASS*) _ok "2 files, baseline 2" ;; + *) _bad "expected PASS at equality, got: $(_verdict "${_out}")" ;; +esac +case "${_out}" in + *"counted 2 file(s), baseline 2"*) _ok "the census line shows what was counted" ;; + *) _bad "no census line" ;; +esac + +echo "-- ARM 3: adding procedure-named code FAILS --" +_out="$(_run grew --base adopted)" +case "$(_verdict "${_out}")" in + *FAIL*) _ok "3 files against a baseline of 2" ;; + *) _bad "expected FAIL on growth, got: $(_verdict "${_out}")" ;; +esac +if printf '%s\n' "${_out}" | grep -q 'the change added 1'; then _ok "the finding says how many"; else _bad "the finding does not say how many"; fi + +echo "-- ARM 4: removing without lowering the baseline FAILS and names the number --" +_out="$(_run shrank-stale --base adopted)" +case "$(_verdict "${_out}")" in + *FAIL*) _ok "1 file against a baseline of 2" ;; + *) _bad "expected FAIL on a stale baseline, got: $(_verdict "${_out}")" ;; +esac +if printf '%s\n' "${_out}" | grep -qF '{"count": 1}'; then _ok "the message gives the number to write"; else _bad "no number to write in the message"; fi + +echo "-- ARM 5: removing and lowering in one change PASSES --" +case "$(_verdict "$(_run shrank-lowered --base adopted)")" in + *PASS*) _ok "the ratchet moved down together" ;; + *) _bad "expected PASS when baseline follows the count" ;; +esac + +echo "-- ARM 6: raising the baseline FAILS --" +_out="$(_run lifted --base adopted)" +case "$(_verdict "${_out}")" in + *FAIL*) _ok "count 3, baseline 3, but the ceiling was lifted" ;; + *) _bad "expected FAIL when the baseline is raised, got: $(_verdict "${_out}")" ;; +esac +if printf '%s\n' "${_out}" | grep -q 'raised from 2 to 3'; then _ok "the finding names both numbers"; else _bad "the raise is not named"; fi + +echo "-- ARM 7: national-standard adapters do not move the count --" +_out="$(_run adapter --base adopted)" +case "$(_verdict "${_out}")" in + *PASS*) _ok "a Zgw path and a Stuf name are excluded" ;; + *) _bad "expected PASS with only adapters added, got: $(_verdict "${_out}")" ;; +esac +case "${_out}" in + *"2 allowlisted"*) _ok "the census says two were allowlisted" ;; + *) _bad "allowlisted count not reported" ;; +esac + +echo "-- ARM 8: a baseline that is not JSON FAILS --" +case "$(_verdict "$(_run badjson --base adopted)")" in + *FAIL*) _ok "an unreadable baseline is a finding, not NOT APPLICABLE" ;; + *) _bad "expected FAIL on a broken baseline" ;; +esac + +echo "-- ARM 9: a checker that cannot run is SKIPPED (wiring) --" +_v="$(HYDRA_GATE_PROCEDURE_CONFIG="${WORK}/does-not-exist.json" _run equal --base adopted | grep -E '^\[gate-119\] procedure-code: (PASS|FAIL|SKIPPED|NOT APPLICABLE)' | head -1)" +case "${_v}" in + *"SKIPPED (wiring)"*) _ok "a missing config is not a pass and not a finding" ;; + *) _bad "expected SKIPPED (wiring), got: ${_v:-}" ;; +esac + +echo "-- ARM 10: no delta base still judges the count --" +case "$(_verdict "$(_run grew --full)")" in + *FAIL*) _ok "a full-scope run holds the count to the baseline" ;; + *) _bad "expected FAIL on a full-scope run of the grown tree" ;; +esac + +echo "" +if [ "${_fail_n}" -eq 0 ]; then + echo "gate-119 procedure-code acceptance: all arms passed." + exit 0 +fi +echo "gate-119 procedure-code 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..f2a44933 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -13355,6 +13355,88 @@ else _skip 116 "connections-declaration" na "this app ships no lib/Settings/connections.json, so it declares no connections to check." fi +# --------------------------------------------------------------------------- +# GATE 119 · procedure-code +# +# Decision 182 (Ruben, 10 Oct 2026): procedures are configuration, code is +# generic. A Dutch procedure (woo, bezwaar, beschikking, vth, subsidie, +# leerplicht, ...) belongs in a schema, a workflow or a template, not in a +# class named after it. This gate counts the PHP, JS, TS and Vue files under +# lib/ and src/ whose path or declared class name carries a procedure token, +# leaves out national-standard adapters (zgw, ztc, stuf, brp, kvk, ... and the +# lib/Adapter and lib/Service/Zgw paths), and holds the count to the number +# committed in `.procedure-code-baseline.json`: +# +# {"count": 12} +# +# A RATCHET, in both directions, the way the custom-widget ratchet (gate 29) +# is one. A change that raises the count FAILS. A change that lowers it FAILS +# too, until it lowers the baseline in the same PR, and the message says to +# what. A change that raises the baseline FAILS (needs a delta base). +# +# THE TOKENS AND THE ALLOWLIST live in scripts/lib/procedure_code_tokens.json, +# one file, and nowhere else. +# +# NO BASELINE FILE IS NOT APPLICABLE, NEVER PASS. The PR that adds this gate +# adds no baseline to any app, and an app with no baseline has had nothing +# measured. Each app opts in by committing the file; until it does, the gate +# says so. DO NOT MERGE THIS GATE BEFORE EVERY FLEET APP HAS A BASELINE, or the +# fleet reads NOT APPLICABLE for a rule it believes is enforced. +# +# NOT A DELTA GATE: the count is of the whole tree against the committed +# number, so it needs no base to judge. A base only adds the baseline-raised +# check. +# --------------------------------------------------------------------------- +if [ -d lib ] || [ -d src ]; then + _pc_log=${HYDRA_GATE_LOG_DIR}/hydra-gate-procedure-code.log + : > "${_pc_log}" + _pc_helper="${SCRIPT_DIR}/lib/check_procedure_code.py" + if [ ! -f "${_pc_helper}" ]; then + _skip 119 "procedure-code" wiring "check_procedure_code.py not found at ${_pc_helper}, so procedure-named code was NOT counted by this run." + elif ! command -v python3 > /dev/null 2>&1; then + _skip 119 "procedure-code" wiring "python3 is not on PATH, so procedure-named code was NOT counted by this run." + else + _pc_args=() + if [ "${HAVE_DELTA_BASE}" = "1" ] && [ -n "${BASE_REF}" ] && [ "${BASE_REF}" != "${_empty_tree:-}" ]; then + _pc_args=(--base "${BASE_REF}") + fi + set +e + python3 "${_pc_helper}" . "${_pc_args[@]}" --list > "${_pc_log}" 2>&1 + _pc_rc=$? + set +e + grep -E '^\[gate-119\] procedure-code: counted ' "${_pc_log}" 2>/dev/null | tail -1 + case "${_pc_rc}" in + 0) + if _helper_finished "${_pc_log}" '^\[gate-119\] procedure-code: counted [0-9]+ file'; then + _pass 119 "procedure-code" + else + _skip 119 "procedure-code" wiring "check_procedure_code.py exited 0 without its census line, so the count is UNVERIFIED by this run. See ${_pc_log}." + fi + ;; + 1) + if _helper_finished "${_pc_log}" '^\[gate-119\] procedure-code: FAIL: [0-9]+ finding'; then + grep -E '^ [^ ]' "${_pc_log}" 2>/dev/null | grep -vE '^ (counted|allowlisted) ' + _pc_n=$(sed -n 's/^\[gate-119\] procedure-code: FAIL: \([0-9]*\) finding.*/\1/p' "${_pc_log}" | tail -1) + _fail 119 "procedure-code" "${_pc_n:-1} finding(s): the count of procedure-named files no longer matches .procedure-code-baseline.json (decision 182: procedures are configuration, code is generic). See ${_pc_log}" + else + # A CRASH IS NOT A FINDING. A Python traceback also exits 1. + _pc_why=$(tail -3 "${_pc_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200) + _skip 119 "procedure-code" wiring "check_procedure_code.py exited 1 without its FAIL summary, so it did not finish and the count is UNVERIFIED by this run. Checker output: ${_pc_why:-}. See ${_pc_log}." + fi + ;; + 4) + _skip 119 "procedure-code" na "this app has no .procedure-code-baseline.json, so there is no committed count to hold the code to. Not a pass: nothing was compared. Commit the baseline to opt in." + ;; + *) + _pc_why=$(tail -3 "${_pc_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200) + _skip 119 "procedure-code" wiring "check_procedure_code.py exited ${_pc_rc} without a verdict, so the count is UNVERIFIED by this run. Checker output: ${_pc_why:-}. See ${_pc_log}." + ;; + esac + fi +else + _skip 119 "procedure-code" na "this repo has neither lib/ nor src/, so there is no code to count." +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..e869b018 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-119 | procedure-code | `test_gate119_procedure_code_scope.sh` + `test_check_procedure_code.py` | builds a real git history in a tmpdir. A ratchet against a committed baseline cannot be covered by a bundle, which has no history to lift a baseline in. ARM 1 pins the convention for the PR that adds the gate: no `.procedure-code-baseline.json` is NOT APPLICABLE, never PASS. ARM 2 is equality, PASS with the census line. ARM 3 adds a procedure-named class and FAILS. ARM 4 removes one without lowering the baseline and FAILS naming the number to write. ARM 5 removes and lowers together and PASSES. ARM 6 raises the baseline and FAILS. ARM 7 adds a `lib/Service/Zgw/` file and a `Stuf` name and the count does not move. ARM 8 is a baseline that is not JSON: FAIL, not NOT APPLICABLE. ARM 9 is a config the checker cannot read: SKIPPED (wiring). ARM 10 runs full scope, since the gate is not a delta gate. The rule-by-rule arms (every configured token, class-name matches, whole-word short tokens, the allowlist by word and by path, scope, ref mode agreeing with working-tree mode) are in the Python suite. | | 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}` |