# -*- coding: utf-8 -*- """D1 §10 JS element-ID integrity gate. For every template under internal/web/templates (the setup wizard templates are deleted, R-554), extract each getElementById('X') / querySelector('#X') literal used by the file's inline JS and assert an id="X" exists in the RENDERED PAGE — or that the ID is created by that file's own JS (innerHTML / insertAdjacentHTML string containing id="X" / id='X'), or is explicitly allowlisted below with a justification. THE RENDERED PAGE, NOT THE FILE (v0.315.0). A page that does {{template "x" .}} renders the ids of whatever defines "x", so the gate follows those includes transitively and unions the ids in. Until the app page split its form into a partial shared by the install wizard and the settings tab, every page was one file and the two readings were the same; a file-scoped check would now convict markup that is demonstrably on the page, which is the fastest way to teach people to route around a gate. Following the includes also makes the gate STRICTER in the direction that matters: an id referenced by a page whose partial no longer defines it is still a failure, and a partial that references an id nothing in its own chain defines is caught where before it was simply a separate file. Exit 1 on any unresolved reference. Run from the repo's controller/ directory: python scripts/template_id_gate.py """ import io, os, re, sys ROOTS = [ os.path.join("internal", "web", "templates"), ] # Dynamic-ID exceptions: (template, id-prefix-or-name) -> justification. # Suffix-parameterized IDs (id + variable) are handled generically below; these are the rest. ALLOW = { # layout.html builds the alert/delete/remove modals entirely in JS and later looks them up. ("layout.html", "alert-modal"): "created by showAlert() via innerHTML in the same file", ("layout.html", "delete-modal"): "created by deleteOrphanStack() via innerHTML", ("layout.html", "remove-modal"): "created by removeStack() via innerHTML", ("layout.html", "confirm-delete-btn"): "created inside the delete-modal innerHTML", ("layout.html", "confirm-remove-btn"): "created inside the remove-modal innerHTML", ("layout.html", "delete-hdd-check"): "created inside the delete-modal innerHTML", ("layout.html", "remove-hdd-check"): "created inside the remove-modal innerHTML", ("layout.html", "remove-backup-check"): "created inside the remove-modal innerHTML", ("layout.html", "remove-hdd-keep-warning"): "created inside the remove-modal innerHTML", ("layout.html", "sync-btn"): "lives on stacks.html; syncTemplates() is shared layout JS guarded by if(!btn)return", ("layout.html", "sync-toast"): "lives on stacks.html; guarded null-check", } GET_RE = re.compile(r"getElementById\(\s*['\"]([A-Za-z0-9_-]+)['\"]\s*\)") GET_DYN_RE = re.compile(r"getElementById\(\s*['\"]([A-Za-z0-9_-]+)['\"]\s*\+") QS_RE = re.compile(r"querySelector\(\s*['\"]#([A-Za-z0-9_-]+)['\"]\s*\)") ID_ATTR_RE = re.compile(r"""id=["']([A-Za-z0-9_{}\. $-]+)["']""") ID_IN_JS_RE = re.compile(r"""id=\\?["']([A-Za-z0-9_-]+)\\?["']""") DEFINE_RE = re.compile(r"""\{\{-?\s*define\s+["']([^"']+)["']""") INCLUDE_RE = re.compile(r"""\{\{-?\s*template\s+["']([^"']+)["']""") def _ids_in(src): return set(ID_ATTR_RE.findall(src)) | set(ID_IN_JS_RE.findall(src)) def _index(paths): """Return (src_by_path, file_of_definition): which file defines each {{define}} name.""" src_by_path, file_of = {}, {} for p in paths: src = io.open(p, encoding="utf-8").read() src_by_path[p] = src for name in DEFINE_RE.findall(src): file_of[name] = p return src_by_path, file_of def _reachable_ids(path, src_by_path, file_of): """Every id this page renders: its own, plus those of every template it includes, transitively. A missing definition is NOT an error here — layout_start and friends are defined in files this walk reaches anyway, and a genuinely undefined template name is html/template's error to raise at parse time, which the render tests already catch. """ seen_files, queue, ids = {path}, [path], set() while queue: p = queue.pop() src = src_by_path.get(p, "") ids |= _ids_in(src) for name in INCLUDE_RE.findall(src): dep = file_of.get(name) if dep and dep not in seen_files: seen_files.add(dep) queue.append(dep) return ids def check(path, src_by_path, file_of): fname = os.path.basename(path) src = src_by_path[path] static_refs = set(GET_RE.findall(src)) | set(QS_RE.findall(src)) dyn_prefixes = set(GET_DYN_RE.findall(src)) # static refs regex also matches the dynamic form's literal — subtract prefixes used with '+' static_refs -= dyn_prefixes defined = _reachable_ids(path, src_by_path, file_of) defined_prefixes = tuple(d.split("{{")[0] for d in defined if "{{" in d or d.endswith("-")) problems = [] for ref in sorted(static_refs): if ref in defined: continue # a template-parameterized id like id="field-{{.EnvVar}}" legitimately renders # ids such as field-SUBDOMAIN — match static refs against those prefixes if defined_prefixes and ref.startswith(defined_prefixes): continue if (fname, ref) in ALLOW: continue problems.append("static #%s not defined in %s" % (ref, fname)) for pref in sorted(dyn_prefixes): # a dynamic lookup 'x-' + var needs SOME id starting with that prefix (template- or JS-created) if any(d.startswith(pref) for d in defined) or pref in defined_prefixes: continue if (fname, pref) in ALLOW: continue problems.append("dynamic prefix #%s* not defined in %s" % (pref, fname)) return problems # R-421 (2026-09-01): any depth, was os.listdir (one level). No template subdirectory exists # today, so this was green and correct — and would have stayed green the moment anyone added # templates/partials/. Measured: a planted file there passed every listdir-based gate. def _html_at_any_depth(root): out = [] for dirpath, _dirs, names in os.walk(root): for fn in sorted(names): if fn.endswith('.html'): out.append(os.path.join(dirpath, fn)) return sorted(out) def main(): bad = [] paths = [] for root in ROOTS: paths += _html_at_any_depth(root) src_by_path, file_of = _index(paths) for _p in paths: bad += check(_p, src_by_path, file_of) if bad: print("INTEGRITY GATE FAILED (%d):" % len(bad)) for b in bad: print(" -", b) sys.exit(1) print("integrity gate OK — every JS element-ID reference resolves in the page it renders on") if __name__ == "__main__": main()