ADR-0056 The battery is a list, or it is a habit
#Context
ADR-0021 reopened the component set on one condition, stated in its Decision in full:
Every component on it … carries the same battery the existing seven carry with no reduced variant of it.
That sentence has governed thirty-five component passes and nothing has ever said what it contains. The battery was a phrase in a record and a habit in whoever was building — reconstructed each time by remembering what the last component needed.
The cost is measurable and it is on the record three times over.
content-lint:scan went red on 7994726. The local run before that commit was fourteen of the fifteen checks that touch a new component, and the fifteenth was not forgotten so much as unlocatable: content-lint:test and content-lint:scan are two scripts, one runs the rule fixtures and one reads the real shipped copy, and nothing anywhere said both were required.
It is the same defect that put the react discipline table at 21 of 25 components while every suite it contains reported green, and the same one that let ADR-0021's build column agree with the package by habit until roster:check joined them. A set narrower than its own subject, with no second party to notice.
A phrase cannot be short. A list can, and a list that is checked cannot be short silently.
#Decision
tools/battery.js is the battery: the artifacts every component has and the checks every component must pass, in one place, read by three consumers.
pnpm battery runs it. pnpm battery:check proves it complete. A reader consults it to see what building a component actually costs.
The order comes from the manifest rather than from the runner, so those three are one list. A runner carrying its own order would be a fourth copy, and the whole point is that there are not four.
#What it asserts, and the fourth is the one that keeps the others honest
1. Every component has every required artifact, and every artifact belongs to a component. Six files: the component, its contract, its stylesheet, its generated token record, its generated component tier, its Storybook specimen. Both directions — a component missing its stylesheet is obvious, and a stylesheet with no component is the file left behind by a rename that a registry goes on importing.
2. Every component has a must-fail fixture, and every fixture has a component or a decision. The type layer is where most of this system's rules live; a component that shipped without one would have every rule it declares enforced by reading.
3. Every check in the battery is a script, and is run by a workflow it names. This is the divergence that went red, made structurally impossible: a check can now be missing from both the local list and CI, or present in both, and nothing else. The evidence is a string that must appear in that workflow's file, because half of these are invoked in CI by their terminal command inside a working-directory rather than by their root alias — a check that only recognised the alias would report the other half missing and teach people to disable it.
4. Every script in the repository is classified — in the battery, or excluded with a reason, or a fixture generator. A battery that went short by one script looks exactly like a battery that is complete, and the only thing that tells them apart is counting against the set of scripts that exist. This is ADR-0041's rule applied to the list itself rather than to any check in it.
#Exclusions are printed by name on every run
The shape ADR-0051 used for modal: a judgement that cannot be derived is printed rather than encoded, because an exception someone has to read is an exception someone reads.
Twelve scripts are excluded. Eleven are generators, dev servers or production builds. The twelfth is docs:check-site, and its exclusion is a gap rather than a decision — it renders the built site and reads it back, which makes it the only check in the repository that looks at what a reader would actually see, and it carries one known pre-existing failure nobody has investigated. Putting it in CI red would train people to ignore a red workflow. Recorded in the manifest so the gap is on the record and on every run's output, instead of being a silence.
Corrected 2026-09-19. docs:check-site joined the battery once ADR-0086 investigated that failure, and docs:build was left behind in the exclusions, classified with the other production builds as gated by a workflow rather than by a component. That was true of the other two and not of this one. docs:check-site starts next start against whatever is already in .next and never builds it, so from the moment the check joined the list and its build did not, a local pnpm battery could render a site from an earlier commit, read it back, and report green about a commit it had never seen. CI was never exposed: the docs workflow builds and then checks, in that order.
Measured rather than reasoned about: a run of docs:check-site against a stale .next reported 20 checks and 0 failing during this session, and the same check against a freshly built one failed on a real overflow the stale build had no way to contain.
docs:build is now in the battery, immediately before the check that reads its output. The battery runs its list in order and stops at the first failure, so adjacency is what makes the ordering a guarantee rather than a hope. storybook:build and proof:build stay excluded, and their original reason still holds: storybook:check and proof:check are both tsc --noEmit, which reads the source rather than a build, so nothing in the battery depends on either artifact.
#Two exception tables, because exceptions in a data file are countable
violations.tsx is status-indicator's must-fail fixture under the name it was given when it was the only one and nothing needed a prefix. Renaming it would move a file that other fixtures' opening comments refer to by name, so the exception is recorded rather than resolved.
SemanticColors.stories.tsx belongs to the token tier, which has no component to hang a specimen on. The check found it on its first run, which is the argument for the check: a story with no component and a story left behind by a rename are the same shape on disk, and the only thing that tells them apart is a sentence saying which one this is. Recorded rather than skipped by a pattern, because a pattern would also skip the next one.
#Observed red before it was trusted green
ADR-0041's discipline, carried out on all five assertions rather than argued about:
| Assertion | Broken by | Reported |
|---|---|---|
| artifacts | moving empty-state.css aside | EmptyState: its stylesheet |
| no orphans | touching Ghost.stories.tsx | unclaimed: apps/storybook/stories/Ghost.stories.tsx |
| must-fail fixture | moving empty-state-violations.tsx aside | no fixture: EmptyState (expected …) |
| every check in CI | mistyping roster:check in budget.yml | "pnpm roster:check" not in budget.yml |
| every script classified | adding a stray script | neither in the battery nor excluded with a reason: stray |
Each restored and green again afterwards.
#Rejected options
Write the battery into ADR-0021 as prose. Where it already is, in the words *"the same battery … with no reduced variant of it"*. It is prose that cannot be short, because prose has no length; the failure mode is not that the sentence is wrong but that it is not a set.
A CI-only check, with no local runner. Half the value and the wrong half. The divergence that went red was a *local* run being short while CI was complete — a check that only ran in CI would have caught the commit and not the habit, and the habit is what produces the next one.
Derive the battery from the workflows. Tempting, because CI is already the fuller list. It fails on the direction that matters: a check nobody added to CI is invisible to a derivation from CI, and that is precisely the failure this record is about. The manifest is the source and CI is checked against it, not the other way round.
Make pnpm battery print everything. Four hundred lines a reader skims, in which a green line and a check that ran over an empty set look identical. It prints one line per check and the full output of the first failure, with the sentence saying why that check is in the battery — because the useful thing to know when a battery goes red is not that it went red, it is which of twenty-two rules the component just broke.
Put docs:check-site in the battery and accept the red. A red workflow that is expected to be red is a workflow nobody reads, and the next real failure in it would arrive invisibly. The honest form is an exclusion with the reason printed on every run, which is what standing item 23 is now about.
#Consequences
Standing item 21 closes. It asked for exactly this and named the shape: *"there is no single list anywhere that says what a new component must pass."*
The remaining 29 roster rows get cheaper and safer. The battery runs in 36 seconds on darwin-arm64, which makes "run the full battery before committing" a thing a person does rather than a thing they mean to.
Nothing about any existing check changes. No check was added, removed or altered; twenty-two that already existed were named, ordered and joined to the workflows that run them.
A new check is now a two-line change with a forced decision. Adding a script to package.json and not classifying it fails battery:check — so the question *is this something a component must pass?* is asked at the moment the answer is known, rather than at the moment a commit goes red.
#Measured
- 22 checks in the battery, each resolving to a root script and each run by one of 5 of 5 workflows.
- 57 scripts in the repository, every one classified: 22 in the battery, 12 excluded with a reason, 23 fixture generators.
- 28 components × 6 artifacts = 168 files, all present; 29 must-fail fixtures — 28 for components, 1 for a decision.
- 2 declared exceptions: one fixture named before the convention existed, one story belonging to a tier — the second found by the check on its first run.
pnpm battery: 22 of 22 in 36s *(darwin-arm64)*.- Five assertions observed red on the breakage each exists to catch, and green again after (ADR-0041).