Preflight
A checklist a human runs sometimes is not a gate. This builds the executable
one: scripts/preflight.js, run before every deploy and in CI, failing the
build on the bug families this project has actually shipped.
Prose rules do not move the number. Two of the repos this framework was measured
against carry 526- and 593-line CLAUDE.md files and have the worst
fix-per-feature ratios. Gates that fail a build are what changes outcomes.
preflight init
Find out what to gate. Run
/learn-from-fixesfirst. Gate the top two or three classes for this repo, not a generic list. If the user insists on starting without that analysis, gate onlysyntax,gates-ranandworkflow-validand say plainly that the rest is guesswork until there is history to read.Those three are the template's defaults because none of them has to know anything about the repo.
workflow-validis the one worth a sentence: a workflow file GitHub refuses fails in 0 seconds, with no jobs and no log, so nothing readable tells you it happened. Measured in one repo on 2026-08-20 — a duplicate top-levelconcurrency:key left a workflow dead for three days while marking every open PRUNSTABLE, past sixty other gates. It is a line scan rather than a parse on purpose: YAML parsers accept duplicate keys and keep the last, so they call a rejected file valid.Copy the template to
scripts/preflight.js:cp "${CLAUDE_PLUGIN_ROOT}/templates/preflight.js" scripts/preflight.jsWire it so it cannot be forgotten — the template fails if you do not:
{ "scripts": { "preflight": "node scripts/preflight.js" } }Add it to CI, and to the deploy ritual ahead of any build step.
Run it. It should fail the first time, on
gates-ran, until wiring is done. That failure is the template proving itself.
preflight add <class> — first, prove the gate does not already exist
Before writing anything, in this order:
- Is it already gated? List the gate ids in the existing file and read the tests the build already runs. A duplicate gate reports the same finding under two names and doubles the noise.
- Was it already rejected? Search the gate file for a recorded decision not to build it. Mature gate files carry these, and they usually contain a reason better than the one you arrived with.
- Measure the population before writing the check. Count what the gate would fire on today, then read every finding. If they are false positives, the gate is wrong — not the codebase. A gate that cries wolf is one people learn to skip, and the skipping generalises to the gates that were right.
A measurement of zero is a fine result: the gate becomes a regression guard. A measurement of sixty is a signal your rule is mis-specified, not that the project has sixty bugs.
When the population is large: ratchet, don't flood
A measurement in the hundreds does not mean "write a gate that fails 400 times". It means the codebase has a real class of debt and the gate has to be a ratchet: record today's violations as a baseline, fail only on new ones, and let the baseline shrink.
Measured example: @typescript-eslint/no-floating-promises on one repo returned
417 findings across 183 files. As error it breaks the build immediately; as
warn it gates nothing and is ignored within a week. As a ratchet it stops the
418th on the day it is written.
The shape:
- Enable the rule and dump today's violations to a checked-in baseline file.
- The gate fails when a violation appears that is not in the baseline.
- The gate also fails when a baseline entry no longer violates — the same
stale-excuse rule as
KNOWN_RED. Otherwise the baseline never shrinks. - Never regenerate the baseline to make a build pass. Regenerating is how a ratchet silently becomes a rubber stamp.
Prefer an existing, battle-tested rule over a hand-written check every time. A config line plus a baseline beats a custom detector you will have to debug — and this project's own history is four hand-written detectors that were wrong on first contact with a real repo.
Record the gates you decide NOT to build
When you conclude a gate should not exist, write that into the gate file as a comment block in the same format as a real gate, ending with why. Something like:
/* [thing] NOT BUILT, ON PURPOSE — <what already covers it>.
Written down here because "we should gate <thing>" is a thought that recurs,
and the next person to have it should find the answer instead of building the
duplicate.
<the specific reason a naive version would be WRONG — e.g. four controls are
deliberately under the floor, measured in a real browser, so a static px gate
fires on all four.>
WHAT IS STILL NOT COVERED, so nobody assumes it is: <the honest gap>. */
This convention is worth more than most gates. A rejected-gate record answers a recurring question permanently, and it is the only thing that stops each new contributor — human or agent — from rebuilding the same wrong check.
Gate shapes
One gate per bug family. Name the gate after the family it prevents, not the mechanism — a future reader needs to know why it exists.
Write the comment above each gate as the incident: what shipped, what it cost, what the gate now prevents. That comment is the reason nobody deletes the gate in six months.
Shapes that work, by class:
| Class | Gate shape |
|---|---|
| Reachability / dead path | Parse the dispatch site; assert every handler is registered at the depth that actually runs |
| Duplicated derivation | Assert only one module computes the value; every other reference imports it |
| Cross-surface consistency | Assert the surfaces showing one value import the same function |
| Cache / key scoping | Assert every cache key includes the account/tenant dimension |
| Copy / i18n drift | Hash the source string per key; fail when the source changed and a locale's hash did not |
| Lifecycle | Assert each addEventListener / setInterval / requestAnimationFrame has a teardown in the same module |
| Config targeting | Assert the env var or project id resolves to the environment the build targets |
| Gate satisfied by a comment | Strip comments with a real lexer before the gate's own regex runs — see below |
The gate that a comment satisfies
The nastiest failure a gate file has, because the gate reports PASS forever and the thing it guards is gone.
Two real instances in one repo, same week:
- An owner-only exemption stripped
//comments before testing for an owner check. A block comment describing a check that had been deleted three months earlier kept granting the exemption. - An image-consent gate ran
/consentV/against raw source. Three of the files it checked mentionconsentVin explanatory prose. Delete the real guard, leave the comment twelve lines above it, and the gate stayed green over Art. 9 special-category health data.
Both were proven by injection — remove the guard, confirm the gate still passes — which is the only way to know a gate is not decoration.
Do not ship this as a scanning gate. Measured on those two files: a detector
for "regex tested against raw file contents" found 54 hits, of which 2 were
bugs. Most raw-source tests are correct — a check looking for readFileSync
calls, or matching a version label, genuinely wants the literal text. A gate at
that precision is one people learn to skip.
Ship the narrow version instead: name the security-critical checks and assert each one runs against a comment-stripped view.
// Not a scan of every regex — a ratchet over the checks that guard something.
const LEXED = ['owner-exemption', 'img-consent', 'authz-order'];
gate('gates-are-lexed', 'every security gate reads code, not prose', () => {
const src = fs.readFileSync(__filename, 'utf8');
const missing = LEXED.filter((id) => {
const body = sliceGate(src, id); // the gate's own body
return !/decomment|codeOnly/.test(body); // …must use the lexer
});
if (missing.length) fail(`these test raw source, so a comment satisfies them: ${missing}`);
else ok(`${LEXED.length} security gates read a lexed view`);
});
Use a real lexer, not two regexes. src.replace(/\/\*[^]*?\*\//g,'').replace(/\/\/.*$/gm,'')
is not a scanner: a // inside a string (every URL) eats the rest of the line,
and a /* inside a string or line comment opens a block that runs to the next
close marker. Measured on one repo, that idiom deleted 128,599 characters of
live code across 5 files — whole functions — from the views assertions ran
against. Those assertions did not fail; they looked at a hole and passed.
Pick the right variant. A comments-only strip keeps string literals; a strip that also blanks literal contents is stronger but blinds any gate whose pattern matches inside a string. One of the two gates above needed each:
sample comments-only +literals
real gate, identifier form true true
real gate, string-literal form true FALSE <- blinded
only a line comment false false
only a block comment false false
Then prove it. Reintroduce the original defect, run preflight, and watch the gate go red. A gate never seen to fail is not known to work — say explicitly in your report that you did this, or that you could not.
preflight verify
Audit the gate file itself:
- Does every gate still run? A gate whose target file was renamed reports "skipped", and in the template that is a hard failure — confirm none are.
- Is every
KNOWN_REDentry still red, and still tied to an open work item? - Is preflight still referenced by CI and by a package script?
The four laws
These are not style preferences. Each cost a production repo a shipped bug.
1. A gate that could not run is not a pass. Gates sit in try/catch so one broken gate cannot take out the run — but routing that catch to a warning lets a gate switch itself off while the run still exits 0. That shipped: renaming one file turned a parity gate into "check skipped" and preflight printed PASS. In this template a skip is a hard failure.
2. Snapshot before you regenerate. If a gate compares a generated artifact against its source, read the artifact from disk before any step regenerates it — otherwise it compares the generator against its own output and is green forever. That shipped two consecutive stale releases.
3. A known-red excuse that now passes is a failure. Track deliberate
failures in KNOWN_RED, keyed by bare gate id, each naming an open work item —
and fail the run when a tracked gate starts passing. A stale excuse is how a
real failure gets waved through.
4. A gate never seen to fail is not known to work. Prove every new gate by reintroducing the defect.
What not to do
- Do not add six gates at once. An unwanted gate gets disabled, and a disabled gate teaches the team that gates are noise.
- Do not gate what a typecheck already catches. Gate what survives it.
- Do not let preflight become slow enough to skip. Keep it offline and parallel; anything touching the network belongs behind an explicit flag.
- Do not silence a red gate by loosening it. Track it in
KNOWN_REDwith the work item, or fix it.