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..491138e3 --- /dev/null +++ b/hydra-gates/scripts/lib/check_manifest_copy_style.py @@ -0,0 +1,197 @@ +#!/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(): + # 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 + 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..8504cd65 100755 --- a/hydra-gates/scripts/run-hydra-gates.sh +++ b/hydra-gates/scripts/run-hydra-gates.sh @@ -10742,6 +10742,78 @@ 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 + +# 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 ! _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) #