The checks, and how to add one
One command, no dependencies:
python3 .github/scripts/check-all.py # -v to see every check's full output
It runs all nine and prints how many ran, which is the number that matters.
Why a runner, and never a shell line
A mistyped shell construct reports "clean" for a check that never ran. That has
now happened seven times in this repository:
- A
$F that expanded to one filename.
- Broken
grep -c arithmetic.
- A zsh glob swallowing
--include.
for c in "python3 …"; do $c; done — zsh treated each whole string as one
command name, printed exit=0 four times, and ran nothing.
$b: followed by a path — zsh parsed it as a history modifier, so
$b:assets/… silently became mainssets/….
node --check assets/js/*.js, which parses only the first glob match.
That one sat in CI for two months, so shared.js had no syntax coverage.
python3 … | head -20; echo "(exit $?)" — $? is head's status, not the
script's, so a check that correctly exited 1 was reported as exit 0.
If you must capture status through a pipe, zsh is ${pipestatus[1]}, not $?.
check-all.py is safe to trust for three specific reasons, and if you change it,
keep all three:
- It is Python, not shell — no globbing, word-splitting or history expansion
happens between the declaration and the process.
- Each check is a literal
(label, argv) tuple, never a string to be parsed.
- It asserts it ran every check it declared and prints the count, so
"9 of 9 checks ran" is part of the output rather than something you infer from
the absence of errors.
A missing interpreter or script is a failure, never a skip (FileNotFoundError
→ exit 127).
The same shape is why the CI test step names its glob and guards on find:
node --test exits 0 when it matches no files, so a moved directory would turn
it into a green no-op.
The fault-injection standard
A new assertion is not finished until you have watched it fail. Plant the
defect it is meant to catch, confirm the check reports it and exits non-zero,
then restore. Record what you planted and what it said in the commit body —
"planted a misspelled transform; the run reported it and exited 1" is worth far
more later than "fix check".
Two traps, both hit repeatedly here:
- Injections that silently do not match.
perl -pi -e and blind
str.replace happily change nothing and leave you verifying an unmodified
file. Use Python with assert new != original before writing.
- Injections that are not actually defects. Replacing both a sidebar label
and its heading leaves them still agreeing, so the label check correctly stays
green and you conclude, wrongly, that it is broken. Likewise a character you
add to test font coverage must genuinely be outside the subset — ★ U+2605 and
∞ U+221E are; most punctuation is not.
Interactive cp/rm/mv aliases will hang a non-interactive shell until it
times out. Save and restore through Python, or call /bin/cp directly.
What the nine can and cannot see
| Check |
Asserts |
check-syntax.py |
every script parses — one node --check per file, never a glob |
check-tokens.py |
token invariants, retired colours/typefaces, undefined var(), inline style=, type pairing, the manifest, webfont coverage |
check-contrast.py |
every pairing against WCAG 2.1 AA in both themes, plus pairings derived from the CSS, plus the measured-ratio comments |
check-classes.py |
classes resolve to rules; load order, asset paths, sitemap and navigation labels hold |
check-versions.py |
every version string agrees with its source of truth |
check-markup.py |
tag balance, duplicate ids, anchors, JSON-LD |
test-analyzer.js |
the migration analyzer under a DOM stub; regenerates mapping-index.json |
node --test .github/test/ |
page ↔ engine ↔ module wiring |
where.py --self-test |
the locator's resolvers still match, by kind not line number |
Five things to know:
- None of them can see the rendered page. Every one is a static reader, so
the entire class of visual defect — a stretched grid, a collapsed flex item, a
truncated label, a card wrapping 3+1 — passes all of them green. A clean run
means "nothing is structurally broken", not "it looks right". Use the
verify-visually skill.
check-contrast.py derives pairings from the stylesheets as well as
asserting a hand-written list, so a new coloured surface is measured without
anyone remembering to add it. Two limits remain: it only sees pairs declared
through tokens in the same rule (or a dark override of one), and it cannot
know which text is large enough for the 3:1 bar, so it holds everything to
4.5:1. It is quiet on success — pass -v for all 58 measurements.
check-classes.py matters most after a restyle. A class that loses its
rule does not error; the element just renders unstyled, which is invisible on
a page with thousands of rows. It reports unused classes but never fails on
them: there is dormant-by-design CSS here, listed by name in the script's own
DORMANT set — the event banner and the ingress2gateway annotation grid, both
built ahead of their content. Run git log -S before deleting anything on
that list.
- The checks live under
.github/ because Pages publishes this branch. A
top-level scripts/ was being served (/scripts/check-tokens.py returned
200); dot-directories 404, because Jekyll runs on this branch and skips
dot-prefixed paths. There is no .nojekyll and adding one would publish
.github/ wholesale — it disables Jekyll rather than configuring it, so the
dot-prefix exclusion goes with it. Anything else that must not be served goes
under .github/ too, which is why the test suite is at .github/test/. Each
script and the test loader derive ROOT by walking up from their own path, so
moving one means fixing that.
.github/workflows/tests.yml runs the same nine, one step each, on every
push and pull request. Pushing to main is deploying and CI finishes at about
the same time the deploy does, so a red run does not stop a bad commit
reaching production. Verify before you push.
Adding a check
- Write it as a standalone script under
.github/scripts/ that exits non-zero
on failure and derives ROOT from its own path.
- Make it quiet on success — print a one-line summary that includes the
counts it asserted, so "OK" can never mean "measured nothing". Put the detail
behind
-v.
- Add a literal
(label, argv) tuple to CHECKS in check-all.py.
- Add a step to
.github/workflows/tests.yml.
- Fault-inject it, per the standard above, and write what you planted into the
commit body.
Adding a test file
Not the same as adding a check — a new .github/test/*.test.js joins the
existing "wiring suite" entry rather than becoming a tenth check. Register it in
both places or it runs in only one of them:
.github/workflows/tests.yml globs .github/test/*.test.js, so CI picks a new
file up on its own.
check-all.py lists the files by name, so a local run silently uses the
smaller suite until the name is added there too.
The asymmetry is deliberate — the glob is guarded against matching nothing, and
a literal list is what keeps check-all.py honest about how many things ran —
but it means the two can disagree without anything saying so.
1---2name: repo-checks3description: How the nine checks work and how to add one — why the runner is Python rather than shell, the seven times a mistyped construct reported clean for a check that never ran, and the fault-injection standard. Use when adding or changing a check, or when one fails.4---56# The checks, and how to add one78One command, no dependencies:910```bash11python3 .github/scripts/check-all.py # -v to see every check's full output12```1314It runs all nine and prints **how many ran**, which is the number that matters.1516## Why a runner, and never a shell line1718A mistyped shell construct reports "clean" for a check that never ran. That has19now happened **seven** times in this repository:20211. A `$F` that expanded to one filename.222. Broken `grep -c` arithmetic.233. A zsh glob swallowing `--include`.244. `for c in "python3 …"; do $c; done` — zsh treated each whole string as one25 command name, printed `exit=0` four times, and ran nothing.265. `$b:` followed by a path — zsh parsed it as a history modifier, so27 `$b:assets/…` silently became `mainssets/…`.286. `node --check assets/js/*.js`, which parses only the **first** glob match.29 That one sat in CI for two months, so `shared.js` had no syntax coverage.307. `python3 … | head -20; echo "(exit $?)"` — `$?` is `head`'s status, not the31 script's, so a check that correctly exited 1 was reported as exit 0.3233If you must capture status through a pipe, zsh is `${pipestatus[1]}`, not `$?`.3435`check-all.py` is safe to trust for three specific reasons, and if you change it,36keep all three:3738- It is **Python, not shell** — no globbing, word-splitting or history expansion39 happens between the declaration and the process.40- Each check is a literal `(label, argv)` tuple, never a string to be parsed.41- It **asserts it ran every check it declared** and prints the count, so42 "9 of 9 checks ran" is part of the output rather than something you infer from43 the absence of errors.4445A missing interpreter or script is a **failure**, never a skip (`FileNotFoundError`46→ exit 127).4748The same shape is why the CI test step names its glob and guards on `find`:49`node --test` exits 0 when it matches no files, so a moved directory would turn50it into a green no-op.5152## The fault-injection standard5354**A new assertion is not finished until you have watched it fail.** Plant the55defect it is meant to catch, confirm the check reports it and exits non-zero,56then restore. Record what you planted and what it said in the commit body —57"planted a misspelled transform; the run reported it and exited 1" is worth far58more later than "fix check".5960Two traps, both hit repeatedly here:6162- **Injections that silently do not match.** `perl -pi -e` and blind63 `str.replace` happily change nothing and leave you verifying an unmodified64 file. Use Python with `assert new != original` before writing.65- **Injections that are not actually defects.** Replacing *both* a sidebar label66 and its heading leaves them still agreeing, so the label check correctly stays67 green and you conclude, wrongly, that it is broken. Likewise a character you68 add to test font coverage must genuinely be outside the subset — ★ U+2605 and69 ∞ U+221E are; most punctuation is not.7071Interactive `cp`/`rm`/`mv` aliases will hang a non-interactive shell until it72times out. Save and restore through Python, or call `/bin/cp` directly.7374## What the nine can and cannot see7576| Check | Asserts |77|---|---|78| `check-syntax.py` | every script parses — one `node --check` per file, never a glob |79| `check-tokens.py` | token invariants, retired colours/typefaces, undefined `var()`, inline `style=`, type pairing, the manifest, webfont coverage |80| `check-contrast.py` | every pairing against WCAG 2.1 AA in both themes, plus pairings derived from the CSS, plus the measured-ratio comments |81| `check-classes.py` | classes resolve to rules; load order, asset paths, sitemap and navigation labels hold |82| `check-versions.py` | every version string agrees with its source of truth |83| `check-markup.py` | tag balance, duplicate ids, anchors, JSON-LD |84| `test-analyzer.js` | the migration analyzer under a DOM stub; regenerates `mapping-index.json` |85| `node --test .github/test/` | page ↔ engine ↔ module wiring |86| `where.py --self-test` | the locator's resolvers still match, by kind not line number |8788Five things to know:89901. **None of them can see the rendered page.** Every one is a static reader, so91 the entire class of visual defect — a stretched grid, a collapsed flex item, a92 truncated label, a card wrapping 3+1 — passes all of them green. A clean run93 means "nothing is structurally broken", not "it looks right". Use the94 `verify-visually` skill.952. **`check-contrast.py` derives pairings from the stylesheets** as well as96 asserting a hand-written list, so a new coloured surface is measured without97 anyone remembering to add it. Two limits remain: it only sees pairs declared98 through tokens in the same rule (or a dark override of one), and it cannot99 know which text is large enough for the 3:1 bar, so it holds everything to100 4.5:1. It is quiet on success — pass `-v` for all 58 measurements.1013. **`check-classes.py` matters most after a restyle.** A class that loses its102 rule does not error; the element just renders unstyled, which is invisible on103 a page with thousands of rows. It reports unused classes but never fails on104 them: there is dormant-by-design CSS here, listed by name in the script's own105 `DORMANT` set — the event banner and the ingress2gateway annotation grid, both106 built ahead of their content. Run `git log -S` before deleting anything on107 that list.1084. **The checks live under `.github/` because Pages publishes this branch.** A109 top-level `scripts/` was being served (`/scripts/check-tokens.py` returned110 200); dot-directories 404, because Jekyll runs on this branch and skips111 dot-prefixed paths. **There is no `.nojekyll` and adding one would publish112 `.github/` wholesale** — it disables Jekyll rather than configuring it, so the113 dot-prefix exclusion goes with it. Anything else that must not be served goes114 under `.github/` too, which is why the test suite is at `.github/test/`. Each115 script and the test loader derive `ROOT` by walking up from their own path, so116 moving one means fixing that.1175. **`.github/workflows/tests.yml` runs the same nine**, one step each, on every118 push and pull request. Pushing to `main` is deploying and CI finishes at about119 the same time the deploy does, so a red run does not stop a bad commit120 reaching production. Verify before you push.121122## Adding a check1231241. Write it as a standalone script under `.github/scripts/` that exits non-zero125 on failure and derives `ROOT` from its own path.1262. Make it **quiet on success** — print a one-line summary that includes the127 counts it asserted, so "OK" can never mean "measured nothing". Put the detail128 behind `-v`.1293. Add a literal `(label, argv)` tuple to `CHECKS` in `check-all.py`.1304. Add a step to `.github/workflows/tests.yml`.1315. Fault-inject it, per the standard above, and write what you planted into the132 commit body.133134## Adding a test file135136Not the same as adding a check — a new `.github/test/*.test.js` joins the137existing "wiring suite" entry rather than becoming a tenth check. Register it in138**both** places or it runs in only one of them:139140- `.github/workflows/tests.yml` globs `.github/test/*.test.js`, so CI picks a new141 file up on its own.142- `check-all.py` lists the files by name, so a local run silently uses the143 smaller suite until the name is added there too.144145The asymmetry is deliberate — the glob is guarded against matching nothing, and146a literal list is what keeps `check-all.py` honest about how many things ran —147but it means the two can disagree without anything saying so.