website refresh: true numbers (56 apps), claims checked against the capability map, ten missing apps, an English twin of every public page (public, choice B), 404 page; site gates 13-18 + 11 decoys; R-902 opened
gates / gates (push) Failing after 13m1s

Part A: claim table documentation/audits/website-refresh-2026-10-08/claims.md; "100% open source" corrected
from the licence read; unbacked claims cut (firewall, RAID, snapshots, new-machine restore, self-managed
mode, household VPN, "never lost"). Part B: dawarich, docmost, grimmory, homebox, karakeep, mealie, metube,
radicale, recipe-importer, sparkyfitness cards; plant-it and wger cut (not offered). Part C: /en/ twins,
nav language switch, hreflang both ways, sitemap with xhtml:link, og-image-en.png. Part D: site_gates.py
twins/lang/same-apps/no-Hungarian/FAQ-JSON-LD/tail. Operator ruling 9 + choice B in 10-localisation.md §11,
§10.7. Register: R-902 (contact mailer source in no repo); R-813, R-784, R-559 annotated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-08 08:29:17 +02:00
parent b119301c6f
commit 67b3fd23d1
115 changed files with 9846 additions and 279 deletions
+17
View File
@@ -1,3 +1,20 @@
## gates — the website's two languages (site_gates.py gates 3, 13–18; 2026-10-08)
- `site_gates.py`: pages are registered as `TWINS` (Hungarian page → English twin) or by name in `NO_TWIN`
(`szolgaltatasok-nonpublic.html`, `404.html`); the R-423 walk still refuses any page in neither. Gate 3 compares
nav/footer per language set (HU against `index.html`, EN against `en/index.html`), normalising only the language
switch's target, and pins the English footer's „households in Hungary". New: 13 twins (both ways, hreflang
hu/en/x-default, switch at the twin, canonical at itself), 14 `lang` per set, 15 the same app logos and card count
on both apps pages, 16 no Hungarian on an English page (accent scan + ASCII-folded stems — whole words for short
stems and the language names, word starts for the rest — over text, attributes, JSON-LD and inline script string
literals; positive and negative controls printed every run; no „please"/„kindly"; English retrieval promises may
not outnumber the Hungarian twin's, `customer_copy_vocab.py`), 17 FAQ JSON-LD == visible answers, 18 nothing
after `</html>`.
- `test_gate_decoys.py`: `decoy()` takes `must=` — the gate's own failure line has to appear, so a decoy convicted
by an unrelated check does not count. Eleven decoys: `site/en-nav-drift`, `hreflang-wrong`, `switch-wrong-twin`,
`lang-attr`, `app-missing-one-side`, `ascii-hungarian-text`, `hungarian-in-script`, `please`, `retrieval-promise`,
`faq-ld-drift`, `text-after-html` — each seen convicting with its line.
## installer — the uninstall knows the kernel lane's files (R-836; unreleased, lands with the next installer tag) (2026-10-07)
- `felhom-host-install.sh` uninstall: removes the two GRUB generators agent v0.152.0's bundle installs
+252 -36
View File
@@ -6,34 +6,62 @@ Gates (all must pass; non-zero exit on any failure):
1. BOM — every website/*.html begins with EF BB BF (byte-checked)
2. emoji — zero emoji/pictographs in website/*.html (codepoint ranges; NEVER grep —
Windows grep false-negatives multibyte emoji, proven in D0)
3. nav — the <nav>…</nav> and <footer>…</footer> blocks of all pages are identical
after stripping the active-link marker
3. nav — the <nav>…</nav> and <footer>…</footer> blocks are identical PER LANGUAGE SET
(Hungarian pages against index.html, English pages against en/index.html) after
stripping the active-link marker and the language switch's target
4. analytics — the umami snippet is present on every public page (nonpublic draft exempt)
5. no-CDN — zero fonts.googleapis.com / fonts.gstatic.com references
6. banned — zero legacy hexes / 999px radius / box-shadow in pages + site.css
7. style — zero embedded <style> blocks in pages (everything lives in site.css)
8. cachebust — every site.css / icons.svg reference carries ?v=<N>
12. download — the two download pages name the same installer file and checksum (R-559)
13. twins — every public Hungarian page has its English twin and back; each pair carries the same
hu/en/x-default alternates; each page's language switch points at its own twin
14. lang — <html lang="hu"> on the Hungarian set, lang="en" on the English set
15. same apps — the app logos on /alkalmazasok and /en/apps are the same set
16. no HU — no Hungarian in the English pages: accent scan PLUS an ASCII-folded, word-bounded stem
scan (an accent-only scan passes ASCII-only Hungarian — 10-localisation.md §7), over
visible text, attributes, JSON-LD and the inline scripts' string literals. Positive and
negative controls are printed on every run. Also: no "please"/"kindly", and no English
retrieval promise beyond what the Hungarian twin makes
17. faq-ld — each FAQ page's FAQPage JSON-LD carries exactly its visible questions and answers
18. tail — nothing after </html> (a stray file-dump summary sat visible below technologiak.html)
"""
import io, os, re, sys
import html as _html, io, json, os, re, sys, unicodedata
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
W = os.path.join(ROOT, "website")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from customer_copy_vocab import RETRIEVAL_STEMS as _RET_HU, RETRIEVAL_STEMS_EN as _RET_EN
PAGES = ["index.html", "kapcsolat.html", "alkalmazasok.html", "technologiak.html",
"biztonsagimentes.html", "gyik.html", "szolgaltatasok-nonpublic.html",
"letoltes.html",
# R-559 (2026-09-18): the English download page. The marketing site stays Hungarian by
# operator ruling 1b; this one page is its English twin because a volunteer who reads no
# Hungarian still has to fetch the installer.
os.path.join("en", "download.html")]
# The public pairs: Hungarian page -> its English twin (2026-10-08, operator ruling 9 in 10-localisation.md §11:
# the whole public site gets an English twin; the Hungarian stays x-default). A new public page needs its twin
# in the same commit — gate 13 refuses one without.
TWINS = {
"index.html": os.path.join("en", "index.html"),
"alkalmazasok.html": os.path.join("en", "apps.html"),
"technologiak.html": os.path.join("en", "technology.html"),
"biztonsagimentes.html": os.path.join("en", "backups.html"),
"gyik.html": os.path.join("en", "faq.html"),
"kapcsolat.html": os.path.join("en", "contact.html"),
"letoltes.html": os.path.join("en", "download.html"), # R-559 (2026-09-18) — the first twin
}
URL = {"index.html": "/", "alkalmazasok.html": "/alkalmazasok", "technologiak.html": "/technologiak",
"biztonsagimentes.html": "/biztonsagimentes", "gyik.html": "/gyik", "kapcsolat.html": "/kapcsolat",
"letoltes.html": "/letoltes", os.path.join("en", "index.html"): "/en/", os.path.join("en", "apps.html"): "/en/apps",
os.path.join("en", "technology.html"): "/en/technology", os.path.join("en", "backups.html"): "/en/backups",
os.path.join("en", "faq.html"): "/en/faq", os.path.join("en", "contact.html"): "/en/contact",
os.path.join("en", "download.html"): "/en/download"}
# Pages with no twin, BY NAME: the hidden prices page stays Hungarian only (brief 2026-10-08), and the 404 page
# is served at whatever URL was wrong — it carries one English line instead of a twin.
NO_TWIN = {"szolgaltatasok-nonpublic.html", "404.html"}
# EN_PAGES carry their OWN nav and footer, in English, and must not be compared with the Hungarian
# set. Everything else — BOM, emoji, banned tokens, analytics, no <style>, cache-busted assets —
# applies to them exactly as to any other page. Stated as a SET rather than a filename test so a
# second English page cannot join by accident.
EN_PAGES = {os.path.join("en", "download.html")}
PAGES = list(TWINS) + list(TWINS.values()) + sorted(NO_TWIN)
EN_PAGES = set(TWINS.values())
HU_PAGES = set(PAGES) - EN_PAGES
ANALYTICS_EXEMPT = {"szolgaltatasok-nonpublic.html"}
ANALYTICS_MARK = "https://stats.felhom.eu/script.js"
SITE = "https://felhom.eu"
BANNED = ["fonts.googleapis.com", "fonts.gstatic.com",
"#0d1117", "#161b22", "#1c2128", "#30363d", "#238636", "#da3633", "#d29922",
@@ -59,9 +87,15 @@ def is_emoji(ch):
])
SWITCH_RE = re.compile(r'<a href="([^"]*)" lang="(hu|en)" hreflang="(hu|en)" class="lang-switch">([^<]*)</a>')
def norm_block(b):
b = b.replace(' class="active"', "")
b = re.sub(r'\s*aria-current="[^"]*"', "", b)
# the language switch is the one nav item whose target differs per page (it points at the page's own
# twin) — gate 13 checks that target; here only its presence and wording must match
b = SWITCH_RE.sub(lambda m: '<a lang-switch="%s">%s</a>' % (m.group(2), m.group(4)), b)
return b
@@ -75,11 +109,15 @@ for _dp, _dns, _fns in os.walk(W):
if _f.endswith(".html"):
_on_disk.add(os.path.relpath(os.path.join(_dp, _f), W))
for _missing in sorted(_on_disk - set(PAGES)):
fail("%s: an HTML page the site gates do not scan — add it to PAGES in scripts/site_gates.py (R-423)" % _missing)
fail("%s: an HTML page the site gates do not scan — add it to TWINS (with its twin) or NO_TWIN in "
"scripts/site_gates.py (R-423)" % _missing)
pages = {}
for p in PAGES:
path = os.path.join(W, p)
if not os.path.exists(path):
fail("%s: listed in the site gates but missing from website/" % p)
continue
raw = io.open(path, "rb").read()
# gate 1: BOM
if raw[:3] != b"\xef\xbb\xbf":
@@ -101,24 +139,32 @@ for p, s in emoji_targets.items():
if total_emoji:
print(" emoji total: %d" % total_emoji)
# gate 3: nav + footer consistency (Hungarian set only — see EN_PAGES)
ref_nav = ref_footer = None
for p, s in pages.items():
if p in EN_PAGES:
# gate 3: nav + footer consistency, per language set
for lang, members, ref in (("hu", HU_PAGES, "index.html"), ("en", EN_PAGES, os.path.join("en", "index.html"))):
if ref not in pages:
continue
nm = re.search(r"<nav>.*?</nav>", s, re.DOTALL)
fm = re.search(r"<footer>.*?</footer>", s, re.DOTALL)
if not nm or not fm:
fail("%s: missing <nav> or <footer>" % p)
continue
nav, foot = norm_block(nm.group(0)), norm_block(fm.group(0))
if ref_nav is None:
ref_nav, ref_footer, ref_page = nav, foot, p
else:
if nav != ref_nav:
fail("%s: <nav> differs from %s (after active-marker normalization)" % (p, ref_page))
if foot != ref_footer:
fail("%s: <footer> differs from %s" % (p, ref_page))
blocks = {}
for p in sorted(members, key=lambda x: (x != ref, x)):
s = pages.get(p)
if s is None:
continue
nm = re.search(r"<nav>.*?</nav>", s, re.DOTALL)
fm = re.search(r"<footer>.*?</footer>", s, re.DOTALL)
if not nm or not fm:
fail("%s: missing <nav> or <footer>" % p)
continue
blocks[p] = (norm_block(nm.group(0)), norm_block(fm.group(0)))
rnav, rfoot = blocks.get(ref, (None, None))
for p, (nav, foot) in blocks.items():
if nav != rnav:
fail("%s: <nav> differs from %s (after active-marker normalization)" % (p, ref))
if foot != rfoot:
fail("%s: <footer> differs from %s" % (p, ref))
if os.path.join("en", "index.html") in pages:
_f = re.search(r"<footer>.*?</footer>", pages[os.path.join("en", "index.html")], re.DOTALL)
if not _f or "households in Hungary" not in _f.group(0):
fail("en/index.html: the English footer no longer says that Felhom serves households in Hungary "
"(the brief's rule: every English page says it once — the shared footer is where it lives)")
# gate 4: analytics presence
for p, s in pages.items():
@@ -145,8 +191,6 @@ for p, s in pages.items():
if c:
fail("%s: %d embedded <style> block(s)" % (p, c))
# gate 8: cache-busting on shared assets
# gate 12 (R-559): the two download pages name the SAME installer file and the SAME checksum.
#
# THE FAILURE THIS EXISTS FOR IS A HALF-DONE RELEASE. Publishing a new ISO means editing a filename,
@@ -174,12 +218,184 @@ if _HU_DL in pages and _EN_DL in pages:
fail("the download pages name DIFFERENT checksums: hu=%s en=%s" % (hu_sha, en_sha))
else:
print(" download pages agree: %s, sha %s…" % (", ".join(hu_iso), (hu_sha or ["-"])[0][:12]))
# gate 8: cache-busting on shared assets
for p, s in pages.items():
for m in re.finditer(r"/assets/(site\.css|icons\.svg)([^\"'#\s>]*)", s):
if not m.group(2).startswith("?v="):
fail("%s: %s referenced without ?v= (got %r)" % (p, m.group(1), m.group(0)))
# gate 13: twins — both ways, matching alternates, the switch at the twin
ALT_RE = re.compile(r'<link rel="alternate" hreflang="([^"]+)" href="([^"]+)">')
for hu, en in TWINS.items():
if hu not in pages or en not in pages:
fail("twin pair %s <-> %s: a page of the pair is missing" % (hu, en))
continue
want = {"hu": SITE + URL[hu], "en": SITE + URL[en], "x-default": SITE + URL[hu]}
for p, other, lang in ((hu, en, "en"), (en, hu, "hu")):
got = dict(ALT_RE.findall(pages[p]))
if got != want:
fail("%s: hreflang alternates %s — the pair %s <-> %s needs exactly %s" % (p, got, hu, en, want))
sw = SWITCH_RE.findall(pages[p])
if len(sw) != 1:
fail("%s: %d language switch links in the page — exactly one, in the nav" % (p, len(sw)))
elif sw[0][0] != URL[other] or sw[0][1] != lang or sw[0][2] != lang:
fail("%s: the language switch points at %r (lang=%s) — its twin is %r (lang=%s)"
% (p, sw[0][0], sw[0][1], URL[other], lang))
canon = re.search(r'<link rel="canonical" href="([^"]+)">', pages[p])
if not canon or canon.group(1) != SITE + URL[p]:
fail("%s: canonical %r — it must point at the page itself (%s)" % (p, canon and canon.group(1), SITE + URL[p]))
print(" twins: %d pairs checked both ways (hreflang, switch, canonical)" % len(TWINS))
# gate 14: the lang attribute
for p, s in pages.items():
m = re.search(r'<html lang="([^"]+)"', s)
want = "en" if p in EN_PAGES else "hu"
if not m or m.group(1) != want:
fail("%s: <html lang=%r> — the %s set needs lang=%r" % (p, m and m.group(1), "English" if want == "en" else "Hungarian", want))
# gate 15: the same apps both ways
def app_logos(s):
return set(re.findall(r'<img src="/assets/([^"]+)" alt="[^"]*" class="app-logo"', s))
_A_HU, _A_EN = "alkalmazasok.html", os.path.join("en", "apps.html")
if _A_HU in pages and _A_EN in pages:
lh, le = app_logos(pages[_A_HU]), app_logos(pages[_A_EN])
cards_hu = len(re.findall(r'<div class="app-card">', pages[_A_HU]))
cards_en = len(re.findall(r'<div class="app-card">', pages[_A_EN]))
if lh != le or cards_hu != cards_en:
fail("the apps pages show DIFFERENT apps: only hu %s, only en %s, cards hu=%d en=%d"
% (sorted(lh - le), sorted(le - lh), cards_hu, cards_en))
else:
print(" apps pages agree: %d cards, %d logos" % (cards_hu, len(lh)))
# gate 16: no Hungarian on the English pages
def fold(s):
s = unicodedata.normalize("NFKD", s)
return "".join(c for c in s if not unicodedata.combining(c)).lower()
HU_LETTER = re.compile(u"[áéíóöőúüűÁÉÍÓÖŐÚÜŰ]")
# ASCII-folded stems. Stems of five letters or more match the START of a word ("jelentkez" convicts
# "Jelentkezz"); short ones, and the two language names, match WHOLE words only — "angol" must not convict
# "Angola", nor "az" convict "Amazon". Reused approach: app-catalog-felhom.eu/scripts/check-copy-i18n.py.
HU_STEMS = ["aldomain", "jelentkez", "oszd meg", "kattints", "valaszd", "magyar", "angol", "felhasznalo", "jelszo",
"nyelv", "beallitas", "alkalmazas", "szerver", "fajl", "mappa", "megosztas", "mentes", "doboz",
"vezerlopult", "kapcsolat", "otthon", "szolgaltatas", "biztonsag", "telepit", "haztartas", "kerdes",
"valasz", "letoltes", "tovabb", "ingyenes", "nyilt", "forraskod", "tarhely", "frissites", "uzemeltet",
"technologia", "hamarosan", "kerlek", "koszon", "uzenet", "targy",
"es", "egy", "vagy", "nem", "igen", "az", "hogy", "mint", "csak", "itt", "ahol", "ezt", "ami", "mert",
"nev", "gyik"]
_WHOLE = {"angol", "magyar"}
HU_RX = [(st, re.compile(r"(?<![a-z0-9])%s" % re.escape(st) + (r"(?![a-z0-9])" if len(st) <= 4 or st in _WHOLE else "")))
for st in HU_STEMS]
BEG = re.compile(r"\b(please|kindly)\b", re.I)
def visible_strings(s):
"""Everything a reader can meet: text, attribute values, JSON-LD, and the string literals of inline scripts
(status messages are shown to the visitor). Not scanned: comments, URLs, and anything marked lang="hu"
(the switch back to Magyar)."""
js = " ".join(re.findall(r"<script(?![^>]*ld\+json)[^>]*>(.*?)</script>", s, flags=re.S))
js = re.sub(r"(?m)^\s*//.*$", " ", js)
lits = [a or b or c for a, b, c in re.findall(r"'([^'\n]*)'|`([^`]*)`|\"([^\"\n]*)\"", js)]
s = re.sub(r"<script(?![^>]*ld\+json)[^>]*>.*?</script>", " ", s, flags=re.S)
s = re.sub(r"<!--.*?-->", " ", s, flags=re.S)
s = re.sub(r'<(?!html\b)([a-z0-9]+)\b[^>]*\blang="hu"[^>]*>.*?</\1>', " ", s, flags=re.S)
attrs = re.findall(r'\b(?:alt|title|aria-label|placeholder|content)="([^"]*)"', s)
s = re.sub(r'(https?://|/)[^\s"<>]*', " ", s)
text = re.sub(r"<[^>]+>", "\n", s)
return [_html.unescape(t).strip() for t in text.split("\n") + attrs + lits if t.strip()]
def hungarian_hits(s):
out = []
for t in visible_strings(s):
t2 = re.sub(r"\b[\w.-]+\.hu\b", " ", t) # Hungarian site names (mindmegette.hu …) are names
if HU_LETTER.search(t2):
out.append(("an accented Hungarian letter", t))
continue
f = fold(t2)
for st, rx in HU_RX:
if rx.search(f):
out.append(("the Hungarian stem %r" % st, t))
break
return out
# controls, printed on every run (workspace rule: a zero from a Hungarian search is suspect without them)
_pos = hungarian_hits(u"<p>Jelentkezz be: admin</p>")
_pos2 = hungarian_hits(u"<p>Kapcsolat</p>")
_neg = hungarian_hits(u"<p>The third best option is in Angola. These documents are kept on the server. "
u"Amazon is not an apps page.</p>")
if not _pos or not _pos2 or _neg:
fail("gate 16 INCONCLUSIVE: matcher controls failed (positive %r/%r must convict, negative %r must not)"
% (_pos, _pos2, _neg))
else:
print(" no-HU matcher controls OK (positive 'Jelentkezz be'/'Kapcsolat' convict, negative 'Angola…' does not)")
for p in sorted(EN_PAGES):
s = pages.get(p)
if s is None:
continue
for why, t in hungarian_hits(s)[:5]:
fail("%s: Hungarian on an English page (%s): %r" % (p, why, t[:120]))
for t in visible_strings(s):
if BEG.search(t):
fail("%s: the site does not beg — no \"please\"/\"kindly\": %r" % (p, t[:120]))
# the retrieval promise: the English page may make it no more often than its Hungarian twin does
hu = next((h for h, e in TWINS.items() if e == p), None)
if hu and hu in pages:
en_n = sum(len(re.findall(rx, t, re.I)) for t in visible_strings(s) for rx in _RET_EN)
hu_n = sum(fold(t).count(fold(st)) for t in visible_strings(pages[hu]) for st in _RET_HU)
if en_n > hu_n:
fail("%s: %d English retrieval promise(s) ('can be restored', 'recoverable' …) against %d in %s — "
"the English promises nothing the Hungarian does not" % (p, en_n, hu_n, hu))
# gate 17: FAQ JSON-LD == visible questions and answers
def faq_pairs(s):
out = []
for q, a in re.findall(r'<button class="faq-question">\s*(.*?)\s*<svg class="faq-chevron".*?'
r'<div class="faq-answer-inner">(.*?)</div></div>', s, re.S):
parts = re.findall(r"<(?:p|li)[^>]*>(.*?)</(?:p|li)>", a, re.S)
out.append((_html.unescape(q.strip()),
" ".join(re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", "", x))).strip() for x in parts)))
return out
for p in ("gyik.html", os.path.join("en", "faq.html")):
s = pages.get(p)
if s is None:
continue
m = re.search(r'<script type="application/ld\+json">(.*?)</script>', s, re.S)
try:
ld = json.loads(m.group(1)) if m else {}
except ValueError as e:
fail("%s: the FAQPage JSON-LD does not parse: %s" % (p, e))
continue
got = [(e.get("name"), (e.get("acceptedAnswer") or {}).get("text")) for e in ld.get("mainEntity", [])]
vis = faq_pairs(s)
if not vis:
fail("%s: found no visible FAQ items — the gate's pattern no longer matches the page" % p)
elif got != vis:
diff = [(i, a, b) for i, (a, b) in enumerate(zip(got, vis)) if a != b][:2]
fail("%s: the FAQPage JSON-LD (%d items) differs from the visible questions/answers (%d): first differences %r"
% (p, len(got), len(vis), diff))
else:
print(" %s: JSON-LD equals the %d visible questions and answers" % (p, len(vis)))
# gate 18: nothing after </html>
for p, s in pages.items():
i = s.rfind("</html>")
if i < 0 or s[i + len("</html>"):].strip():
fail("%s: text after </html> (%r) — it renders on the page" % (p, s[i + 7:i + 87] if i >= 0 else "no </html>"))
if fails:
print("\nSITE GATES FAILED: %d problem(s)" % len(fails))
sys.exit(1)
print("site gates OK — BOM, emoji=0, nav/footer consistent, analytics present, no CDN, no legacy tokens, no <style>, cache-busted assets")
print("site gates OK — BOM, emoji=0, nav/footer per language, analytics, no CDN, no legacy tokens, no <style>, "
"cache-busted assets, twins + hreflang + switch, lang, same apps, no Hungarian in English, FAQ JSON-LD, clean tail")
+58 -4
View File
@@ -69,7 +69,12 @@ COVERS = {
"stands": ("R-819: a stand citing a register id that exists in NEITHER register, and a stand "
"marked 'walked' whose only source is a register row (a green dot from a label); "
"plus the genuine article — a stand citing only a CLOSED row, which must PASS"),
"site": "R-423: a NEW page under website/ that PAGES does not list (the gate used to scan 7 of 9)",
"site": ("R-423: a NEW page under website/ that PAGES does not list (the gate used to scan 7 of 9); and "
"(2026-10-08, the English twin) an English nav drifting from en/index, a wrong hreflang, a "
"switch at the wrong twin, lang=\"hu\" on an English page, an app missing from one apps page, "
"ASCII-only Hungarian in English text and in a script message, a 'please', an English "
"retrieval promise the Hungarian does not make, a FAQ JSON-LD drifting from the visible "
"answer, and text after </html> — each must convict WITH its own failure line"),
"script-tests": ("R-885: FIVE cases in scripts/test_script_tests_gate.py, run from here: a suite that PRINTS "
"OK and exits 1 (label without fact), a failing suite three levels deep (scope is a walk), "
"an empty scripts/ tree (checked nothing), the genuine article (must pass), the nested mark"),
@@ -102,8 +107,9 @@ def gate(script, args=()):
return p.returncode, p.stdout + p.stderr
def decoy(name, script, plant, args=(), expect="convict"):
"""plant() is a callable returning a cleanup callable."""
def decoy(name, script, plant, args=(), expect="convict", must=None):
"""plant() is a callable returning a cleanup callable. `must`: a text the gate's output has to carry — a decoy
convicted by some OTHER check proves nothing about the check it was built for."""
global ran
ran += 1
cleanup = plant()
@@ -112,7 +118,10 @@ def decoy(name, script, plant, args=(), expect="convict"):
finally:
cleanup()
want_nonzero = (expect == "convict")
if (rc != 0) != want_nonzero:
if must and want_nonzero and rc != 0 and must not in out:
fails.append("%s: the gate failed, but NOT with %r — the decoy convicted for another reason\n%s"
% (name, must, out[-700:]))
elif (rc != 0) != want_nonzero:
fails.append("%s: decoy %s (rc=%d)\n%s" %
(name, "PASSED - LIVE HOLE" if want_nonzero else "was wrongly convicted",
rc, out[-700:]))
@@ -492,6 +501,51 @@ def replace_in(path, old, new):
return _plant
# ── site: the English twin (2026-10-08) ─────────────────────────────────────────────────────────────
# Each decoy is a shape a real session produces: a nav edited on one page, an hreflang copied from the wrong
# page, a translator leaving a Hungarian word without accents, a status message left in a script.
_WEB = os.path.join(ROOT, "website")
_EN = lambda f: os.path.join(_WEB, "en", f)
decoy("site/en-nav-drift", "site_gates.py",
replace_in(_EN("technology.html"), '<li><a href="/en/apps">Apps</a></li>', '<li><a href="/en/apps">Applications</a></li>'),
must="en/technology.html: <nav> differs from en/index.html")
decoy("site/hreflang-wrong", "site_gates.py",
replace_in(_EN("faq.html"), '<link rel="alternate" hreflang="en" href="https://felhom.eu/en/faq">',
'<link rel="alternate" hreflang="en" href="https://felhom.eu/en/apps">'),
must="en/faq.html: hreflang alternates")
decoy("site/switch-wrong-twin", "site_gates.py",
replace_in(_EN("apps.html"), '<a href="/alkalmazasok" lang="hu" hreflang="hu" class="lang-switch">',
'<a href="/" lang="hu" hreflang="hu" class="lang-switch">'),
must="en/apps.html: the language switch points at")
decoy("site/lang-attr", "site_gates.py",
replace_in(_EN("backups.html"), '<html lang="en">', '<html lang="hu">'),
must="the English set needs lang='en'")
decoy("site/app-missing-one-side", "site_gates.py",
replace_in(_EN("apps.html"), '<img src="/assets/radicale-logo.svg" alt="Radicale" class="app-logo">', ""),
must="the apps pages show DIFFERENT apps")
decoy("site/ascii-hungarian-text", "site_gates.py",
replace_in(_EN("index.html"), "Contact form</a>", "Kapcsolatfelveteli urlap</a>"),
must="en/index.html: Hungarian on an English page (the Hungarian stem 'kapcsolat')")
decoy("site/hungarian-in-script", "site_gates.py",
replace_in(_EN("contact.html"), "showStatus('Fill in every required field.', 'error-state');",
"showStatus('Kerlek, toltsd ki az osszes mezot.', 'error-state');"),
must="en/contact.html: Hungarian on an English page (the Hungarian stem 'kerlek')")
decoy("site/please", "site_gates.py",
replace_in(_EN("contact.html"), '<div class="error-message" id="subjectError">Choose a topic.</div>',
'<div class="error-message" id="subjectError">Please choose a topic.</div>'),
must="en/contact.html: the site does not beg")
decoy("site/retrieval-promise", "site_gates.py",
replace_in(_EN("technology.html"), "<li>Reproducible anywhere</li>",
"<li>Reproducible anywhere</li>\n <li>Every file can still be restored, whatever happens</li>"),
must="en/technology.html: 2 English retrieval promise(s)")
decoy("site/faq-ld-drift", "site_gates.py",
replace_in(_EN("faq.html"), "<p>Just e-mail <strong>support@felhom.eu</strong>.", "<p>Just e-mail <strong>help@felhom.eu</strong>."),
must="en/faq.html: the FAQPage JSON-LD")
decoy("site/text-after-html", "site_gates.py",
replace_in(_EN("apps.html"), "</body>\n</html>", "</body>\n</html>\n=====\nSummary: 13 files included\n"),
must="en/apps.html: text after </html>")
# ── hostinstall (R-426, 2026-10-06) ──────────────────────────────────────────────────────────────
#
# Each decoy is a one-line edit a real session would make to felhom-host-install.sh, leaving the LABEL