dc0ab8b2a8
gates / gates (push) Successful in 2s
NEW-APP-CHECKLIST.md: the reviewer's draft reviewed - 60 rows in 10 groups, each with how/why and a since date; 7 rows added, 16 sharpened, 9 wrong claims fixed. onboarding/_TEMPLATE.md (one line per id), onboarding/wger.md (the pilot, exempt app, 11 open rows each a register row), onboarding/EXISTING-APPS-GAPS.md (read only, from scripts/onboarding_gaps.py). Gate onboarding (scripts/check-onboarding.py) in --fast: a template directory not among the 53 published before 2026-10-01 needs a complete record; decoys in test_gate_decoys.py (16 cases, 5 gate mutants seen red). CLAUDE.md, REUSE.md 5, README point to it. No template changed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
261 lines
12 KiB
Python
261 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""check-onboarding.py — a NEW catalog app carries a complete onboarding record (NEW-APP-CHECKLIST.md).
|
|
|
|
WHY. Operator request 2026-10-01: before a new app is offered, it is mapped and tested against one checklist —
|
|
storage, database, the first admin, health checks, memory, updates, mail, the household's text. A checklist that
|
|
nothing enforces is a wish; this gate is the enforcement. It reads files only (no network, no containers, no git
|
|
history), so it runs in `catalog_gates.py --fast`: the pre-push hook and CI.
|
|
|
|
THE RULE, for every directory under templates/ that is NOT in EXEMPT:
|
|
1. `onboarding/<app>.md` exists, its `app:` line names the app, and its `opened: YYYY-MM-DD` is a real date
|
|
on or after CUTOFF and not in the future.
|
|
2. It answers every checklist id whose `since` date is on or before `opened:` (an id added later binds only
|
|
apps opened after it — the checklist stores the date per id).
|
|
3. No answer is `open`.
|
|
4. Every `done` names evidence that EXISTS: a non-empty file, or a directory holding at least one non-empty
|
|
file. An empty directory is a label, not evidence (R-410: a `mkdir` once turned a release gate green).
|
|
5. Every `n/a` carries a reason of at least MIN_REASON_WORDS words.
|
|
For an EXEMPT app that has a record anyway (wger, the pilot): the record must be well-formed (known ids, no
|
|
duplicate, a valid status, `done` evidence that exists, `n/a` with a reason) — `open` and missing ids are allowed.
|
|
And `onboarding/_TEMPLATE.md` must carry every checklist id, so a row added to the checklist cannot be forgotten
|
|
in the template a new app copies.
|
|
|
|
EVIDENCE PATHS are written from the workspace root. `app-catalog-felhom.eu/…` resolves against THIS checkout
|
|
(whatever its directory is called — the CI runner checks out into another name). Any other first component
|
|
(`felhom.eu/…`) resolves against the checkout's parent directory; if that sibling repository is not there (the CI
|
|
runner fetches this repo alone) the path is NOT CHECKED and the count is printed — the pre-push hook on DooPlex has
|
|
the sibling and checks it. Same shape as engine-major's shallow-clone skip: said out loud, never silent.
|
|
|
|
WHY THE 53 ARE LISTED BY NAME and not "new since commit X": the CI runner fetches at --depth 1 and has no history
|
|
to diff (R-452), and a list is a fact a reader can check. A directory not on the list is new, whatever its age.
|
|
|
|
Run from the repo root: python3 scripts/check-onboarding.py [--root=DIR] [--today=YYYY-MM-DD]
|
|
Exit 0 all records complete · 1 a record is missing or incomplete · 2 the checklist itself cannot be read.
|
|
"""
|
|
import datetime
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
CATALOG_PREFIX = "app-catalog-felhom.eu/"
|
|
CUTOFF = "2026-10-01"
|
|
MIN_REASON_WORDS = 4
|
|
STATUSES = ("done", "n/a", "open")
|
|
|
|
# The 53 apps in the catalog on 2026-10-01 (catalog main 9c5eae9 + the checklist commit). They were published
|
|
# before the checklist existed; re-testing them is not owed (operator default 2026-10-01, may be reversed). What the
|
|
# catalog shows for each is onboarding/EXISTING-APPS-GAPS.md. NEVER add a name here to let a new app through.
|
|
EXEMPT = frozenset("""
|
|
actualbudget adventurelog audiobookshelf bentopdf bookstack calcom calibre-web claper code-server
|
|
crafty-controller docmost emby ghost gitea glance gokapi grafana gramps-web home-assistant homebox homepage
|
|
immich jellyfin kimai komga mealie n8n navidrome nextcloud onlyoffice opengist outline paperless-ngx papra
|
|
plant-it plex privatebin radarr rallly recipe-importer romm seerr sonarr sparkyfitness tandoor termix
|
|
uptime-kuma vaultwarden vikunja wanderer wger wishlist zipline
|
|
""".split())
|
|
|
|
ROW_CHECKLIST = re.compile(r"^\|\s*(\d+\.\d+)\s*\|\s*(\d{4}-\d{2}-\d{2})\s*\|")
|
|
ROW_RECORD = re.compile(r"^(\d+\.\d+)\s*\|\s*([^|]*?)\s*\|\s*(.*?)\s*$")
|
|
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
|
|
def read_checklist(root):
|
|
path = os.path.join(root, "NEW-APP-CHECKLIST.md")
|
|
if not os.path.isfile(path):
|
|
return None, "NEW-APP-CHECKLIST.md is missing"
|
|
ids = {}
|
|
for line in open(path, encoding="utf-8"):
|
|
m = ROW_CHECKLIST.match(line)
|
|
if m:
|
|
cid, since = m.group(1), m.group(2)
|
|
if cid in ids:
|
|
return None, "checklist id %s appears twice" % cid
|
|
try:
|
|
datetime.date.fromisoformat(since)
|
|
except ValueError:
|
|
return None, "checklist id %s has a bad since date %r" % (cid, since)
|
|
ids[cid] = since
|
|
if not ids:
|
|
return None, "no `| <id> | <since> |` rows found in NEW-APP-CHECKLIST.md"
|
|
return ids, None
|
|
|
|
|
|
def evidence_ok(p):
|
|
if os.path.isfile(p):
|
|
return os.path.getsize(p) > 0
|
|
if os.path.isdir(p):
|
|
for dp, _dn, fn in os.walk(p):
|
|
for f in fn:
|
|
if os.path.getsize(os.path.join(dp, f)) > 0:
|
|
return True
|
|
return False
|
|
|
|
|
|
def resolve(root, rel, unchecked):
|
|
"""Return (abs path or None, problem or None). None, None = not checkable here (counted)."""
|
|
rel = rel.strip().strip("`")
|
|
if not rel or rel.startswith("/") or ".." in rel.split("/"):
|
|
return None, "evidence %r is not a workspace-relative path" % rel
|
|
if rel.startswith(CATALOG_PREFIX):
|
|
return os.path.join(root, rel[len(CATALOG_PREFIX):]), None
|
|
first = rel.split("/", 1)[0]
|
|
sib = os.path.join(os.path.dirname(root), first)
|
|
if not os.path.isdir(sib):
|
|
unchecked.append(rel)
|
|
return None, None
|
|
return os.path.join(os.path.dirname(root), rel), None
|
|
|
|
|
|
def check_record(root, app, path, checklist, strict, today, unchecked):
|
|
probs = []
|
|
# A row inside an HTML comment is not an answer: `<!-- … -->` is how a row is set aside, and a commented-out
|
|
# `1.4 | done | …` must not count as done (the label without the fact, R-421). Line numbers are kept.
|
|
text = re.sub(r"<!--.*?-->", lambda m: "\n" * m.group(0).count("\n"),
|
|
open(path, encoding="utf-8").read(), flags=re.S)
|
|
head = {}
|
|
for line in text.splitlines():
|
|
m = re.match(r"^(app|opened):\s*(\S+)\s*$", line)
|
|
if m and m.group(1) not in head:
|
|
head[m.group(1)] = m.group(2)
|
|
if head.get("app") != app:
|
|
probs.append("its `app:` line reads %r, not %r" % (head.get("app"), app))
|
|
opened = head.get("opened", "")
|
|
if not DATE.match(opened):
|
|
probs.append("no `opened: YYYY-MM-DD` line")
|
|
opened = None
|
|
else:
|
|
try:
|
|
datetime.date.fromisoformat(opened)
|
|
except ValueError:
|
|
probs.append("`opened: %s` is not a real date" % opened)
|
|
opened = None
|
|
if strict and opened:
|
|
if opened < CUTOFF:
|
|
probs.append("`opened: %s` is before the checklist existed (%s) — a new app cannot be opened earlier"
|
|
% (opened, CUTOFF))
|
|
if opened > today:
|
|
probs.append("`opened: %s` is in the future (today %s)" % (opened, today))
|
|
|
|
seen = {}
|
|
for n, line in enumerate(text.splitlines(), 1):
|
|
m = ROW_RECORD.match(line)
|
|
if not m:
|
|
continue
|
|
cid, status, rest = m.group(1), m.group(2).lower(), m.group(3)
|
|
if cid not in checklist:
|
|
probs.append("line %d: id %s is not in the checklist" % (n, cid))
|
|
continue
|
|
if cid in seen:
|
|
probs.append("line %d: id %s answered twice (first on line %d)" % (n, cid, seen[cid]))
|
|
continue
|
|
seen[cid] = n
|
|
if status not in STATUSES:
|
|
probs.append("line %d: id %s has status %r — one of done / n/a / open" % (n, cid, status))
|
|
elif status == "open":
|
|
if strict:
|
|
probs.append("id %s is OPEN: %s" % (cid, rest or "(no note)"))
|
|
elif status == "n/a":
|
|
if len(re.findall(r"[^\W\d_]{2,}", rest)) < MIN_REASON_WORDS:
|
|
probs.append("id %s is n/a with no reason of %d+ words: %r" % (cid, MIN_REASON_WORDS, rest))
|
|
else: # done
|
|
paths = rest.split(" — ", 1)[0]
|
|
parts = [p for p in (x.strip() for x in paths.split(" ; ")) if p]
|
|
if not parts:
|
|
probs.append("id %s is done with no evidence path" % cid)
|
|
for p in parts:
|
|
ap, why = resolve(root, p, unchecked)
|
|
if why:
|
|
probs.append("id %s: %s" % (cid, why))
|
|
elif ap and not evidence_ok(ap):
|
|
probs.append("id %s is done but its evidence %s does not exist (or is empty)" % (cid, p))
|
|
if strict and opened:
|
|
missing = [c for c, s in sorted(checklist.items(), key=lambda kv: [int(x) for x in kv[0].split(".")])
|
|
if s <= opened and c not in seen]
|
|
if missing:
|
|
probs.append("missing id(s): %s" % ", ".join(missing))
|
|
return probs
|
|
|
|
|
|
def main(argv):
|
|
root = ROOT
|
|
today = datetime.date.today().isoformat()
|
|
for a in argv:
|
|
if a.startswith("--root="):
|
|
root = os.path.abspath(a.split("=", 1)[1])
|
|
elif a.startswith("--today="):
|
|
today = a.split("=", 1)[1]
|
|
elif a in ("--all",):
|
|
pass # the runner passes --all through; every template is judged anyway
|
|
elif not a.startswith("-"):
|
|
pass # app scope is not used: the rule is about the whole templates/ tree
|
|
else:
|
|
print("unknown option %s" % a)
|
|
return 2
|
|
checklist, err = read_checklist(root)
|
|
if err:
|
|
print("ONBOARDING GATE INCONCLUSIVE — %s" % err)
|
|
return 2
|
|
|
|
tdir = os.path.join(root, "templates")
|
|
odir = os.path.join(root, "onboarding")
|
|
apps = sorted(d for d in os.listdir(tdir) if os.path.isdir(os.path.join(tdir, d)))
|
|
new = [a for a in apps if a not in EXEMPT]
|
|
problems, unchecked = [], []
|
|
|
|
tpl = os.path.join(odir, "_TEMPLATE.md")
|
|
if not os.path.isfile(tpl):
|
|
problems.append(("_TEMPLATE", ["onboarding/_TEMPLATE.md is missing"]))
|
|
else:
|
|
body = re.sub(r"<!--.*?-->", "", open(tpl, encoding="utf-8").read(), flags=re.S)
|
|
have = {m.group(1) for m in (ROW_RECORD.match(l) for l in body.splitlines()) if m}
|
|
lack = sorted(set(checklist) - have, key=lambda c: [int(x) for x in c.split(".")])
|
|
if lack:
|
|
problems.append(("_TEMPLATE", ["onboarding/_TEMPLATE.md lacks checklist id(s): %s" % ", ".join(lack)]))
|
|
|
|
for app in new:
|
|
rec = os.path.join(odir, app + ".md")
|
|
if not os.path.isfile(rec):
|
|
problems.append((app, ["NEW app with no onboarding record — copy onboarding/_TEMPLATE.md to "
|
|
"onboarding/%s.md and answer every id (NEW-APP-CHECKLIST.md)" % app]))
|
|
continue
|
|
p = check_record(root, app, rec, checklist, True, today, unchecked)
|
|
if p:
|
|
problems.append((app, p))
|
|
|
|
exempt_records = []
|
|
if os.path.isdir(odir):
|
|
for f in sorted(os.listdir(odir)):
|
|
name = f[:-3] if f.endswith(".md") else None
|
|
if not name or name.startswith("_") or name.isupper() or "-GAPS" in name.upper():
|
|
continue
|
|
if name not in apps:
|
|
problems.append((name, ["onboarding/%s.md names no template directory" % f]))
|
|
elif name in EXEMPT:
|
|
exempt_records.append(name)
|
|
p = check_record(root, name, os.path.join(odir, f), checklist, False, today, unchecked)
|
|
if p:
|
|
problems.append((name, p))
|
|
|
|
print("onboarding gate — %d checklist ids; %d template dirs: %d exempt (published before %s), %d new; "
|
|
"%d exempt app(s) with a record (shape-checked): %s"
|
|
% (len(checklist), len(apps), len(apps) - len(new), CUTOFF, len(new), len(exempt_records),
|
|
", ".join(exempt_records) or "none"))
|
|
if unchecked:
|
|
print(" NOT CHECKED here (sibling repository absent — the pre-push hook on DooPlex checks them): %d path(s)"
|
|
% len(unchecked))
|
|
for u in unchecked[:10]:
|
|
print(" %s" % u)
|
|
if problems:
|
|
print("ONBOARDING GATE FAILED:")
|
|
for app, ps in problems:
|
|
for p in ps:
|
|
print(" %s: %s" % (app, p))
|
|
return 1
|
|
print("onboarding gate OK")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|