Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions hydra-gates/scripts/lib/check_manifest_copy_style.py
Original file line number Diff line number Diff line change
@@ -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))
72 changes: 72 additions & 0 deletions hydra-gates/scripts/run-hydra-gates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-<empty>}. 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)
#
Expand Down
Loading