Files
felhom.eu/scripts/decoy_coverage_gate.py
T
admin 970616b72b
gates / gates (push) Failing after 11m41s
New apps 2026-10-10: Grocy and LubeLogger on the website; Monica stopped
The catalogue side is app-catalog-felhom.eu b7f0f7c. Here: the evidence, the
website, the register and the operator's view.

Website
- Two cards in the Otthon & Eletmod / Home & Lifestyle section of BOTH apps
  pages, with assets: grocy-logo.svg is grocy's own icon with its single fill
  made white like the other logos, lubelogger-logo.png is the app's own icon
  with its dark background dropped and the mark made white (the rule
  SparkyFitness's PNG follows), and six screenshots of each app's own UI with a
  household's own data, taken headless on the bench from the published template.
- The app count moved 56 -> 58 in 15 places per language set, both languages,
  and the open-source tile 49 -> 51. Checked by asking the same patterns for the
  new number afterwards. marketing/facebook/COPY.md still says 56 and is NOT
  changed: the post it carries is already scheduled.

Evidence
- documentation/audits/new-apps-2026-10-10/ — FIT.md (checklist group 0 for all
  three, with the Hungarian-UI column), the bench and box transcripts, the
  memory samples, the screenshots and the gate runs.

Register: 137 -> 139 rows, 2 opened, 0 closed.
- R-926 after a remove-keeping-backups and restore, nobody has checked what the
  app page shows for an after_install app's generated password. Measured here:
  LubeLogger is safe (its login is derived from the environment at every start,
  the new password signs in, the data is back); grocy's install password still
  signs in from the restored database but the deployed environment no longer
  carries ADMIN_PASSWORD at all. Seven apps are in the class.
- R-927 Monica, stopped at checklist 0.2 with the measurements, waiting on the
  operator.

Gate scripts: the same console trap in nineteen of them and in repo_gates.py
itself, where it ABORTED THE WHOLE RUNNER at the first gate — printing a
non-ASCII character on this workstation's cp1250 console raised
UnicodeEncodeError before the gate had decided anything, and reuse_refs_check.py
died while printing a NOTE. All now reconfigure their own streams. Red-proof
that the remaining script-test failures are not mine: test_due_checks_gate.py
fails the same 5 of 42 with the change reverted.
2026-10-10 12:34:05 +02:00

204 lines
9.4 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""decoy_coverage_gate.py — every registered gate ships with a decoy test (R-421).
Usage: python3 scripts/decoy_coverage_gate.py <repo-root> [<repo-root> ...]
Exit 0 covered-or-registered · 1 a gate has neither a decoy nor an exemption · 2 inconclusive.
WHY THIS EXISTS. Four times in one week a gate turned out to be matching a NAME instead of the thing
it named — R-410 (a `mkdir` turned the release gate green), R-400 (seven debug controls answering
nothing), R-378 (a status word inside a sentence), R-419 (a phrase inside prose, including prose
saying the marker was absent). All four were found by accident. **The gates are the machinery that
enforces everything else here, and they were the one part nothing checked.** The 2026-09-01 sweep
read all 29 and fooled 16 of them.
A survey fixes what it finds once. This makes the next one impossible to add quietly: **a NEW gate
with no decoy fails immediately**, and the gates not yet covered are listed BY NAME below, each with
its row, so the remaining debt is visible and shrinking rather than forgotten in a Python literal.
R-329's shape, deliberately, because this project already trusts it: walk everything, register the
exceptions by name, and let a new one fail rather than slip through.
⚠ COVERAGE IS A DECLARATION, NOT A GREP. Each decoy suite exports `COVERS = {gate: why}` and this
gate AST-parses that literal. Searching the test file for a gate's name would be exactly the
substring-for-fact shape this whole sweep exists to find — the gate that checks for label-matching
must not itself match on a label.
"""
import ast
import io
import os
import sys
# A gate's own console must not decide its verdict. These scripts quote back Hungarian copy, file
# paths and arrow characters; a Windows console here is cp1250, and printing one of them raised
# UnicodeEncodeError *before the gate had decided anything* — reuse_refs_check.py died while printing
# a NOTE, which the runner then reported as a failure (2026-10-10). Same trap class as
# poster_facts_gate.py's, which was found by its own red-proof.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError, OSError): # pragma: no cover - old Python, or a pipe
pass
# ── EXEMPTIONS — each carries its row and one line saying why it is not covered YET ─────────────
# Dated 2026-09-01. This list is DEBT, not a settled state: R-426 owns it and names every entry.
EXEMPT = {
# felhom.eu
# felhom-controller: covered by controller/scripts/test_gate_decoys.py since 2026-10-06 (R-426)
# felhom-agent and app-catalog: covered by their own scripts/test_gate_decoys.py since 2026-10-06 (R-426)
}
RUNNERS = {
"felhom.eu": os.path.join("scripts", "repo_gates.py"),
"felhom-controller": os.path.join("controller", "scripts", "controller_gates.py"),
"felhom-agent": os.path.join("scripts", "agent_gates.py"),
"app-catalog-felhom.eu": os.path.join("scripts", "catalog_gates.py"),
}
SUITES = {
"felhom.eu": os.path.join("scripts", "test_gate_decoys.py"),
"felhom-controller": os.path.join("controller", "scripts", "test_gate_decoys.py"),
"felhom-agent": os.path.join("scripts", "test_gate_decoys.py"),
"app-catalog-felhom.eu": os.path.join("scripts", "test_gate_decoys.py"),
}
def literal_from(path, name):
"""AST-parse a module-level literal assignment. Never imports — importing a test RUNS it."""
if not os.path.isfile(path):
return None
try:
tree = ast.parse(io.open(path, encoding="utf-8").read())
except SyntaxError as e:
return ("ERROR", "%s does not parse: %s" % (path, e))
for node in tree.body:
if isinstance(node, ast.Assign):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == name:
try:
return ast.literal_eval(node.value)
except Exception:
return ("ERROR", "%s in %s is not a literal" % (name, path))
return None
def gates_of(runner_path):
"""The GATES table's LABELS, AST-read — the runner is never imported and never literal_eval'd.
Only the label is taken, deliberately. Three of the four runners build their script paths with
`os.path.join(...)`, so the table as a whole is not a literal — but every label is a plain string
constant, which is all this gate needs. Evaluating the whole row would make this gate fail on
three repos for a reason that has nothing to do with coverage.
"""
if not os.path.isfile(runner_path):
return None
try:
tree = ast.parse(io.open(runner_path, encoding="utf-8").read())
except SyntaxError:
return None
for node in tree.body:
if not isinstance(node, ast.Assign):
continue
if not any(isinstance(t, ast.Name) and t.id == "GATES" for t in node.targets):
continue
if not isinstance(node.value, (ast.List, ast.Tuple)):
return None
out = []
for row in node.value.elts:
if isinstance(row, (ast.Tuple, ast.List)) and row.elts:
first = row.elts[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
out.append(first.value)
return out
return None
def main(argv):
roots = argv[1:] or ["."]
problems, inconclusive, lines = [], [], []
total = covered = exempt = 0
stale = []
for root in roots:
root = os.path.abspath(root)
# ⚠ IDENTIFY THE REPO BY ITS RUNNER, NOT BY ITS DIRECTORY NAME.
#
# This gate asked `os.path.basename(root)` and looked the answer up in RUNNERS — a NAME
# standing in for a FACT, which is the exact class this gate was written to catch. It went
# INCONCLUSIVE on its first CI run because Gitea's act-runner checks the repo out into a
# directory called `hostexecutor`. **The gate that hunts label-matching was matching a
# label.** Measured 2026-09-01, CI job 490. Now: whichever registered runner FILE actually
# exists under this root is what the repo is.
name, runner = None, None
for cand, rel in RUNNERS.items():
if os.path.isfile(os.path.join(root, rel)):
name, runner = cand, os.path.join(root, rel)
break
if name is None:
inconclusive.append("no known gate runner found under %s — tried %s"
% (root, ", ".join(sorted(RUNNERS.values()))))
continue
labels = gates_of(runner)
if labels is None:
inconclusive.append("%s: could not read the GATES table from %s" % (name, runner))
continue
covers = literal_from(os.path.join(root, SUITES[name]), "COVERS") or {}
if isinstance(covers, tuple):
inconclusive.append("%s: %s" % (name, covers[1]))
covers = {}
for label in labels:
total += 1
if label in covers:
covered += 1
lines.append(" COVERED %-22s %-20s %s" % (name, label, covers[label][:60]))
# R-426 (2026-10-06): an exemption for a gate that HAS a decoy is a dead entry that keeps
# the debt list longer than the debt (controller/offbox-rename sat here after R-425 gave it
# decoys). Named, not convicted: the decoy lands in a sibling repo, and failing THIS repo's
# push for another repo's good news would couple the two for no safety gain.
if (name, label) in EXEMPT:
stale.append((name, label))
elif (name, label) in EXEMPT:
exempt += 1
lines.append(" exempt %-22s %-20s %s" % (name, label, EXEMPT[(name, label)][:60]))
else:
problems.append((name, label))
print("decoy-coverage gate — %d registered gate(s): %d with a decoy, %d registered exempt, "
"%d UNACCOUNTED" % (total, covered, exempt, len(problems)))
for l in sorted(lines):
print(l)
if stale:
print()
for repo, label in stale:
print(" STALE EXEMPTION: %s / %s has a decoy now — delete its EXEMPT entry (R-426)" % (repo, label))
if inconclusive:
print()
for i in inconclusive:
print(" INCONCLUSIVE: %s" % i)
if not problems:
print("\ndecoy-coverage gate INCONCLUSIVE — an undetermined result is never a pass")
return 2
if problems:
print()
print("CONVICTED — these gates have neither a decoy test nor a registered exemption:")
for repo, label in problems:
print(" %s / %s" % (repo, label))
print()
print("A gate ships with a decoy test that has been SEEN TO FAIL. Construct the label")
print("without the fact, run the gate, and assert it convicts — then name the gate in that")
print("repo's scripts/test_gate_decoys.py COVERS map.")
print("If no plausible decoy exists, say so: add it to EXEMPT here with a row number and one")
print("line of reason. An honest exemption is a result; a silent gap is how R-410 happened.")
return 1
print("\ndecoy-coverage gate OK — every registered gate has a decoy or a named exemption (R-426)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))