From 4e13007e8d995473a832888b798ba9a35facb419 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 26 Aug 2026 11:50:37 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(gates):=20gate-96=20manifest-copy-styl?= =?UTF-8?q?e=20=E2=80=94=20the=20voice=20rule,=20enforced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Conduction voice already bans em-dashes. voice.md section 8 is explicit, and the `writing` skill's REVIEW mode uses a walkthrough `steps[0].body` em-dash as its worked example. The rule was never wrong. It shipped anyway. A sweep on 2026-08-26 found 126 violations across 12 apps: shillinq 47 · hrmq 15 · pipelinq 14 · opencatalogi 12 · decidesk 8 larpingapp 7 · openconnector 6 · scholiq 6 · docudesk 4 · hermiq 3 procest 2 · softwarecatalog 2 The one that surfaced it was a user opening dossiq and reading step 1 on screen: "a quick spin through case handling - we'll register a case". WHY A GATE WHEN THE RULE EXISTS. A skill is opt-in: it applies when an author chooses to load it, and manifest copy gets written by hand or by an agent that never invoked it. Nothing downstream reads the prose at all - `check:manifest` validates against a JSON Schema, and JSON Schema has no opinion about writing. So the rule lived in a document, the copy shipped past it, and the only detector was a human noticing. That is the shape a mechanical gate is for. NOT WALKTHROUGH-ONLY. My first sweep read only walkthrough steps and found 25. Reading every user-visible field found 126. The rest sit in setup-wizard steps, menu labels and widget empty states - "No open debtor invoices - everything is paid." A walkthrough-scoped checker would have called ten of those twelve apps clean. READS THE FRAGMENTS. `src/manifest.d/*.json` is merged into the manifest at runtime via require.context, so a checker that opens only `src/manifest.json` is blind to whatever they add. Eight fleet apps use them; shillinq has 87. Verified on real trees, all three exit paths: dossiq (2 known) rc=1, 2 findings, both the reported ones buildiq (clean) rc=0 a dir with no manifest rc=4, so the gate reports na, not a false pass crashed checker SKIP(wiring) "UNVERIFIED", never PASS That last one is the property worth having. `_helper_finished` requires the terminal "checked N manifest string(s)" line, so a checker that dies mid-run cannot report success over the findings it never reached. En-dashes between digits are allowed, per voice.md, so "2020-2024" passes while "RGS - Referentie GrootboekSchema" is caught. Backticked spans and URLs are excluded before the double-dash test, since `--flag` in an example is not prose. Full-tree rather than diff-scoped, for the reason gates 84, 93, 94 and 95 give: the violations are already in the tree, and a diff-scoped version reports clean on every PR that does not happen to touch the manifest. --- .../scripts/lib/check_manifest_copy_style.py | 189 ++++++++++++++++++ hydra-gates/scripts/run-hydra-gates.sh | 61 ++++++ 2 files changed, 250 insertions(+) create mode 100755 hydra-gates/scripts/lib/check_manifest_copy_style.py diff --git a/hydra-gates/scripts/lib/check_manifest_copy_style.py b/hydra-gates/scripts/lib/check_manifest_copy_style.py new file mode 100755 index 00000000..9c0ddaa1 --- /dev/null +++ b/hydra-gates/scripts/lib/check_manifest_copy_style.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: EUPL-1.2 +""" +Gate 96 — manifest-copy-style. + +The Conduction voice bans em-dashes. `writing/references/voice.md` §8 says it +plainly: "Em-dashes (—) and double-dashes (--) are AI tells. Replace with a +period, a comma, or a colon." The `writing` skill's REVIEW mode even uses a +walkthrough `steps[0].body` em-dash as its worked example. + +The rule was already right. On 2026-08-26 a sweep of shipped walkthrough copy +found TWENTY-FIVE em-dashes across NINE apps anyway: + + opencatalogi 6 · pipelinq 5 · docudesk 3 · larpingapp 3 + decidesk 2 · procest 2 · softwarecatalog 2 · hermiq 1 · shillinq 1 + +Only openbuild was clean. The first one a user actually reported was +dossiq step 1: "a quick spin through case handling — we'll register a case". + +WHY A GATE, WHEN THE RULE ALREADY EXISTS. Because a skill is opt-in. It +applies when an author chooses to load it, and manifest copy is routinely +written by hand or by an agent that never invoked the writing skill. Nothing +in the pipeline reads that copy at all: `check:manifest` validates the manifest +against a JSON Schema, and JSON Schema has no opinion about prose. So the rule +lived in a document, the copy shipped past it, and the only detector was a +human noticing on screen. + +That is the shape a gate is for. The rule is not new; the enforcement is. + +WHAT IS CHECKED. Every user-visible string in the manifest — the fields a +reader actually sees: + + title, body, task, label, description, emptyText, placeholder, + subtitle, helpText + +in `src/manifest.json` AND in `src/manifest.d/*.json`. The fragments matter: +they are merged into the manifest at runtime by `require.context`, so a +checker that reads only `src/manifest.json` is blind to whatever they add. +Eight fleet apps use fragments; shillinq has 87 of them. + +WHAT IS NOT CHECKED. En-dashes (–) between digits, which voice.md explicitly +permits for numeric ranges ("2020–2024"). An en-dash anywhere else is flagged. +URLs and identifiers are skipped: a `--` inside a query string is not prose. + +FULL-TREE, not diff-scoped. The em-dashes are already in the tree. A +diff-scoped version would report clean on every PR that does not happen to +touch the manifest, which is nearly all of them, and the 25 would sit there +indefinitely wearing a green tick. +""" + +import json +import os +import re +import sys + +# Fields a human actually reads. Kept explicit rather than "every string in +# the tree" so that route names, component ids, icon names and schema slugs — +# none of which are prose — cannot produce a finding. +VISIBLE_FIELDS = ( + "title", + "body", + "task", + "label", + "description", + "emptyText", + "placeholder", + "subtitle", + "helpText", +) + +EM_DASH = "—" +EN_DASH = "–" + +# An en-dash BETWEEN DIGITS is a numeric range, which voice.md §8 allows. +# Anything else is the AI tell. +NUMERIC_RANGE = re.compile(r"(?<=\d)%s(?=\d)" % EN_DASH) + +# `--` inside a URL or a CLI example is not an em-dash substitute. Strip the +# obvious non-prose carriers before looking for it. +URLISH = re.compile(r"https?://\S+|`[^`]*`") + + +def _findings_for(value): + """ + Return the list of style violations in one string. + + :param value: the string to inspect. + :return: list of short reason strings; empty when the value is clean. + """ + out = [] + if EM_DASH in value: + out.append("em-dash") + stripped = NUMERIC_RANGE.sub("", value) + if EN_DASH in stripped: + out.append("en-dash outside a numeric range") + prose = URLISH.sub("", value) + if "--" in prose: + out.append("double-dash") + return out + + +def _walk(node, path, hits, counter): + """ + Walk a manifest node, collecting findings for user-visible strings. + + :param node: the current dict / list / scalar. + :param path: JSON-ish path to the current node, for the report. + :param hits: accumulator of (path, value, reasons). + :param counter: single-element list used as a mutable string count. + :return: None + """ + if isinstance(node, dict): + for key, value in node.items(): + child = "%s.%s" % (path, key) if path else key + if key in VISIBLE_FIELDS and isinstance(value, str) and value.strip(): + counter[0] += 1 + reasons = _findings_for(value) + if reasons: + hits.append((child, value, reasons)) + else: + _walk(value, child, hits, counter) + elif isinstance(node, list): + for index, value in enumerate(node): + _walk(value, "%s[%d]" % (path, index), hits, counter) + + +def _manifest_files(root): + """ + Collect the manifest and every runtime-merged fragment. + + :param root: repository root to scan. + :return: list of file paths, in load order. + """ + found = [] + main = os.path.join(root, "src", "manifest.json") + if os.path.isfile(main): + found.append(main) + frag_dir = os.path.join(root, "src", "manifest.d") + if os.path.isdir(frag_dir): + for name in sorted(os.listdir(frag_dir)): + if name.endswith(".json"): + found.append(os.path.join(frag_dir, name)) + return found + + +def main(argv): + """ + Entry point. + + :param argv: argv, where argv[1] is the repository root (default "."). + :return: 0 clean, 1 findings, 4 no manifest in this repo. + """ + root = argv[1] if len(argv) > 1 else "." + files = _manifest_files(root) + if not files: + print("checked 0 manifest string(s)") + return 4 + + hits = [] + counter = [0] + for path in files: + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError) as exc: + # A manifest that will not parse is gate-manifest-validation's + # finding, not this gate's. Say so and keep going rather than + # reporting a style verdict over a file never read. + print("SKIP %s: unreadable (%s)" % (os.path.relpath(path, root), exc)) + continue + _walk(data, os.path.relpath(path, root), hits, counter) + + for where, value, reasons in hits: + excerpt = value if len(value) <= 120 else value[:117] + "..." + print("FAIL %s: %s" % (where, ", ".join(reasons))) + print(" %s" % excerpt) + + if hits: + print("") + print("voice.md §8: em-dashes and double-dashes are AI tells.") + print("Replace with a period, a comma, or a colon.") + print("En-dashes are allowed only between digits, as a numeric range.") + + print("checked %d manifest string(s)" % counter[0]) + return 1 if hits else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index 7e4f2dca..8928959a 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -10742,6 +10742,67 @@ else _fail 95 "adr-number-collision" "${_adrc_n} ADR number collision(s) or title/filename mismatch(es) — see ${_adrc_log}" fi +# --------------------------------------------------------------------------- +# GATE 96 — manifest-copy-style +# +# The Conduction voice bans em-dashes. `writing/references/voice.md` section 8 +# says it plainly, and the `writing` skill's REVIEW mode uses a walkthrough +# `steps[0].body` em-dash as its worked example. The rule was already right. +# +# MEASURED 2026-08-26. Shipped manifest copy carried 126 violations across 12 +# apps: shillinq 47, hrmq 15, pipelinq 14, opencatalogi 12, decidesk 8, +# larpingapp 7, openconnector 6, scholiq 6, docudesk 4, hermiq 3, procest 2, +# softwarecatalog 2. The one a user reported was dossiq step 1 on screen: +# "a quick spin through case handling - we'll register a case". +# +# WHY A GATE WHEN THE RULE ALREADY EXISTS. A skill is opt-in. It applies when +# an author chooses to load it, and manifest copy is routinely written by hand +# or by an agent that never invoked the writing skill. Nothing downstream reads +# the prose at all: `check:manifest` validates against a JSON Schema, and JSON +# Schema has no opinion about writing. So the rule lived in a document, the +# copy shipped past it, and the only detector was a human noticing on screen. +# That is precisely the shape a mechanical gate is for. +# +# NOT WALKTHROUGH-ONLY. A first sweep that read only walkthrough steps found +# 25. Reading every user-visible field found 126 - the rest sit in setup +# wizard steps, menu labels and widget empty states +# (`No open debtor invoices - everything is paid.`). +# +# READS THE FRAGMENTS TOO. `src/manifest.d/*.json` is merged into the manifest +# at runtime via require.context, so a checker that opens only +# `src/manifest.json` is blind to whatever the fragments add. Eight fleet apps +# use them; shillinq has 87. +# +# FULL-TREE, not diff-scoped, for the reason gates 84, 93, 94 and 95 give: the +# violations are already in the tree, and a diff-scoped version reports clean +# on every PR that does not happen to touch the manifest. +# +# NOTE ON PLACEMENT: top level, outside any `_FAILED` guard - a gate that only +# runs once everything else passed is green-but-dead. +# --------------------------------------------------------------------------- +_mcs_log=${HYDRA_GATE_LOG_DIR}/hydra-gate-manifest-copy-style.log +: > "${_mcs_log}" +set +e +python3 "${SCRIPT_DIR}/lib/check_manifest_copy_style.py" . > "${_mcs_log}" 2>&1 +_mcs_rc=$? +# `set +e`, not `set -e`: errexit off is the state this script actually runs +# in. See the note at the top of this file. +set +e + +if [ "${_mcs_rc}" -eq 0 ]; then + _pass 96 "manifest-copy-style" +elif [ "${_mcs_rc}" -eq 4 ]; then + _skip 96 "manifest-copy-style" na "this repo ships no src/manifest.json, so it declares no manifest copy to style-check. See ${_mcs_log}." +elif ! _helper_finished "${_mcs_log}" '^checked [0-9]+ manifest string'; then + # A CRASH IS NOT A FINDING. + _mcs_why=$(head -3 "${_mcs_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200) + _skip 96 "manifest-copy-style" wiring "check_manifest_copy_style.py exited ${_mcs_rc} without printing its terminal 'checked N manifest string(s)' summary, so manifest copy is UNVERIFIED by this run. Checker output: ${_mcs_why:-}. See ${_mcs_log}." +else + _mcs_n=$(grep -cE '^FAIL ' "${_mcs_log}" 2>/dev/null || true) + case "${_mcs_n}" in ''|*[!0-9]*) _mcs_n=1 ;; esac + _fail 96 "manifest-copy-style" "${_mcs_n} manifest string(s) break voice.md section 8 (em-dash / double-dash) - see ${_mcs_log}" +fi + # --------------------------------------------------------------------------- # GATE 83 — contract-surface-shift (ADR-084) # From abe559853535fd1fab35f006ce15daeaf62f7532 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 26 Aug 2026 12:12:32 +0200 Subject: [PATCH 2/3] fix(gate-96): skip internal `_meta` blocks, they are not user copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version of this checker walked every key, which meant it read `_meta.description` — the per-fragment provenance block carrying spdx-license, spdx-copyright, change, adr and a note describing the fragment for developers. 38 shillinq fragments have one. That was 22 of shillinq's 47 findings, and every one of them was wrong. Worse than noise: acting on them would have rewritten build provenance as if it were user-facing prose, so the gate would go green while the tree got worse. A gate that manufactures work is not cheaper than no gate. Now skips any key beginning with `_`, which is the convention for internal blocks rather than a special case for this one name. Corrected fleet totals, measured after the change: before 126 across 12 apps after 97 across 12 apps shillinq 47 -> 24, scholiq 6 -> 1 The remaining findings are real. `CnPageHeader` renders a page's `config.description` under its title, so users currently read things like "REQ-REC-006: pre-close summary - matched count, unmatched GL/bank counts". Those are developer notes in a user-visible field, and the em-dash is the least of what is wrong with them. All three exit paths re-verified after the change: dossiq rc=1, buildiq rc=0, a directory with no manifest rc=4. --- hydra-gates/scripts/lib/check_manifest_copy_style.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hydra-gates/scripts/lib/check_manifest_copy_style.py b/hydra-gates/scripts/lib/check_manifest_copy_style.py index 9c0ddaa1..491138e3 100755 --- a/hydra-gates/scripts/lib/check_manifest_copy_style.py +++ b/hydra-gates/scripts/lib/check_manifest_copy_style.py @@ -111,6 +111,14 @@ def _walk(node, path, hits, counter): """ if isinstance(node, dict): for key, value in node.items(): + # Underscore-prefixed blocks are internal metadata, never rendered. + # `_meta` is the live case: 38 shillinq fragments carry one, holding + # spdx-license, spdx-copyright, change, adr and a description that + # documents the fragment for developers. Flagging those produced 22 + # false findings out of 47 on that app alone, and "fixing" them + # would have rewritten build provenance as if it were user copy. + if key.startswith("_"): + continue child = "%s.%s" % (path, key) if path else key if key in VISIBLE_FIELDS and isinstance(value, str) and value.strip(): counter[0] += 1 From b18b55e1b4ab166ecf7cafe76319687d87b14757 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 26 Aug 2026 14:56:21 +0200 Subject: [PATCH 3/3] fix(gate-96): an empty scope is `na`, never PASS test_gate_empty_scope_never_passes.sh failed this gate, correctly: FAIL - gate(s) 96 reported PASS over a scope that excludes every planted defect - this is the .github#374 defect. Each of them printed the same word as a gate that read the whole tree and found it clean. That is exactly right. My first version returned exit 4 only when the repo ships no src/manifest.json at all. A manifest that exists but declares no user-visible string produced `checked 0` and exit 0, which the shell block turned into PASS - a gate announcing "clean" having inspected nothing. Now both shapes route to `_skip_empty_scope`, which says what was enumerated and states plainly that nothing was inspected so this is NOT a pass. The condition reads the checker's own `checked N` line rather than trusting the exit code, because the count is the measurement and the exit code is only a summary of it. The test message ends "do NOT add it to _ARM6_ALLOWED unless you can state what it computed." I did not. An allowlist would have made this green while leaving the gate unable to tell "no violations" from "no input", which is the whole failure this suite exists to prevent. Verified after the change: a manifest with pages:[] and menu:[] gives `checked 0` and takes the na branch; openbuild gives checked=49 findings=0 and still PASSES; hrmq (654) and pipelinq (600) still FAIL with their real counts. Empty scope and clean tree no longer print the same word. --- hydra-gates/scripts/run-hydra-gates.sh | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/hydra-gates/scripts/run-hydra-gates.sh b/hydra-gates/scripts/run-hydra-gates.sh index 8928959a..8504cd65 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -10789,10 +10789,21 @@ _mcs_rc=$? # in. See the note at the top of this file. set +e -if [ "${_mcs_rc}" -eq 0 ]; then +# An empty scope must not print the same word as a clean full-tree read. +# `checked 0` means the checker ran and inspected NOTHING — either the repo +# ships no manifest, or the manifest it ships declares no user-visible string. +# Both are `na`. Reporting PASS there is the .github#374 defect, and +# test_gate_empty_scope_never_passes.sh caught exactly that in this gate's +# first version: it passed over a planted tree whose every defect was out of +# scope, indistinguishable from a gate that read the whole tree and found it +# clean. +_mcs_checked=$(sed -n 's/^checked \([0-9]\{1,\}\) manifest string.*/\1/p' "${_mcs_log}" 2>/dev/null | tail -1) +case "${_mcs_checked}" in ''|*[!0-9]*) _mcs_checked=0 ;; esac + +if [ "${_mcs_rc}" -eq 4 ] || { [ "${_mcs_rc}" -eq 0 ] && [ "${_mcs_checked}" -eq 0 ]; }; then + _skip_empty_scope 96 "manifest-copy-style" "user-visible manifest string (a title / body / task / label / description / emptyText / placeholder / subtitle / helpText in src/manifest.json or src/manifest.d/*.json)" +elif [ "${_mcs_rc}" -eq 0 ]; then _pass 96 "manifest-copy-style" -elif [ "${_mcs_rc}" -eq 4 ]; then - _skip 96 "manifest-copy-style" na "this repo ships no src/manifest.json, so it declares no manifest copy to style-check. See ${_mcs_log}." elif ! _helper_finished "${_mcs_log}" '^checked [0-9]+ manifest string'; then # A CRASH IS NOT A FINDING. _mcs_why=$(head -3 "${_mcs_log}" 2>/dev/null | tr '\n' ' ' | cut -c1-200)