Compare a component-state contract with an exported story inventory and name the states nobody covered — with an exact reference for every gap.
- Repository: edilec/component-state-matrix
- Area: Design Systems
- License: MIT
- Dependencies: none, at runtime or in development. Node 22+,
node:test,node:assert/strict.
Two JSON documents go in.
The contract says which states each component must have a story for — default, loading,
error, empty, disabled, focus — and which variants it must show.
The export is an inventory somebody produced from their story tooling: one record per story, saying which component it belongs to, which state it shows, whether it is skipped, and whether a visual snapshot was recorded for it.
What comes out is the gap between them: which component, which state, which stories exist for that component today, and which document to fix.
A state matrix is the thing everyone agrees on in a design review and nobody checks afterwards. The
error state gets a story, the empty state does not, and six months later the empty state ships
with the copy from the mock. Meanwhile the one story that did cover loading got skipped during a
flaky week and stayed that way — so the matrix is green in a spreadsheet and red in the product.
Both of those are visible in an export somebody already produces. This tool reads it.
It measures nothing. It starts no story runner, opens no browser, renders nothing, takes no screenshot, loads no snapshot image, opens no socket and resolves no host. A story id is an opaque name, not an address. The export is the only evidence there is, and every sentence in the report is a sentence about the two documents it was given.
It writes nothing. There is no --out, it creates no directory and it modifies no file, so no
output-destination guard applies to it.
It does not judge what a state looks like. This is the line the tool exists not to cross, so it is worth being exact about:
A covered state means a story exists for it and the export does not mark that story skipped. That is all it means. Nothing here has rendered the state or compared it to anything, so the report never calls a state verified, correct or approved.
Where a contract needs more than existence, it lists the state under visualEvidence. Then a story
that exists and runs is deliberately not enough: the export must also record that a snapshot was
taken. Even then the claim stays narrow — the tool knows what the export claims was recorded, and
it has not looked at the image.
node bin/component-state-matrix.mjs --root examples/covered --now 2026-09-15T09:00:00Z
# exit 0 -- every required state has a running story
node bin/component-state-matrix.mjs --root examples/gaps --now 2026-09-15T09:00:00Z
# exit 1 -- five gaps, each naming the component, the state and the stories that do existA gap from the second run, as it reaches stdout:
{
"ruleId": "state-missing",
"severity": "error",
"message": "The component \"DataTable\" is required to have a story for the \"error\" state, and stories.json records none.",
"location": { "file": "component-states.json", "pointer": "/components/1/requiredStates/2" },
"evidence": "stories recorded for DataTable: data-table--default, data-table--empty, data-table--loading",
"suggestion": "Write a story that shows \"DataTable\" in its error state, and export the inventory again."
}A state contract:
{
"schemaVersion": "1",
"requiredStates": ["default", "disabled", "focus"],
"components": [
{
"id": "Button",
"requiredStates": ["default", "disabled", "focus", "loading"],
"variants": ["primary", "secondary"],
"visualEvidence": ["loading"]
},
{ "id": "TextField" }
]
}A component with no requiredStates of its own inherits the top-level list, and its findings are
anchored at the component rather than at a /requiredStates element the document does not have.
A story export:
{
"schemaVersion": "1",
"generated": "2026-09-14T09:00:00Z",
"stories": [
{ "id": "button--loading", "component": "Button", "state": "loading", "variant": "primary", "visualSnapshot": true },
{ "id": "button--focus", "component": "Button", "state": "focus", "skipped": true }
]
}state is required on every story. A story whose state the export does not record satisfies
nothing, and no default is guessed: inventing one is how an unknown becomes a pass. skipped and
visualSnapshot default to false when absent, but a value that is neither true nor false is
refused rather than reinterpreted.
generated is required too. An export with no instant cannot be told apart from one written last
month, and every coverage verdict is only as current as the document it came from.
The full table, with a sentence on when each rule fires, is in
docs/state-rules.md. In outline:
| Rule | Severity | What it means |
|---|---|---|
state-missing |
error | A required state has no story at all. |
state-only-skipped |
error | Every story covering it is skipped, so it is exercised by nothing. |
variant-missing |
error | A required variant has no story, or only skipped ones. |
visual-evidence-missing |
error | The contract asked for a recorded snapshot; a running story exists and no snapshot does. |
component-without-stories |
error | The export records no story for a governed component at all. |
story-component-unknown |
warning | A story covers a component the contract does not govern. |
story-state-unlisted |
info | A story covers a state the contract does not require. |
| document rules | error | Either document is malformed, contradictory, stale or governs nothing. |
| evidence rules | error, incomplete | A document could not be read, decoded, parsed, or was cut short by a limit, or a record in it was refused. |
Severity lives in one frozen ruleId -> severity table; a finding takes its value from there and an
unknown rule id throws. That table is asserted against the documented catalog in both directions —
and, because three agreeing declarations are satisfied by one coordinated edit, every rule is also
pinned behaviourally by driving real documents through the real CLI and asserting the process exit
code — and, for the rules that also mark the run incomplete, where exit 2 comes from the flag rather
than from the severity, by asserting the error and warning counters the report carries.
| Code | Meaning |
|---|---|
0 |
both documents were read and no error-severity rule fired |
1 |
both documents were read and at least one error-severity rule fired |
2 |
invalid configuration, or evidence that could not be obtained |
Exit 2 has two shapes, and a consumer piping stdout must handle both:
| Situation | stdout | status |
|---|---|---|
| Invalid configuration, unknown option, bad usage | empty | no report — the run never had a subject |
| A document that could not be read, decoded or parsed | a report | incomplete |
| A story record that was refused, so the export index is short of one | a report | incomplete |
An incomplete run is never a pass, and an unread document is never reported as an absent one —
and neither is an unread record of one.
Each of these is pinned by a test that fails when the behaviour is removed — not by a test that reads a declaration.
- Unknown is never a pass. A document that could not be read, decoded or parsed, a limit reached
mid-run, and a time budget that expired all produce
incompleteand exit 2. A budget that expires before a state was examined leaves it neither covered nor missing. - A record dropped out of an index is not a record the document lacks — on both sides of the
comparison. Every gap here is a claim of absence read off an index, so an index a record was
dropped out of cannot support one. A refused story record makes the run
export-partly-unread,incompleteand exit 2, and nothing is reported as covered or missing: the unreadable field may be the component, the state or the variant, so no record can be attributed and no absence established. A refused component entry is reported and fails the run, andstory-component-unknown— the claim that the contract does not govern something — is withheld for a component the contract named and then refused, and withheld entirely once an entry was refused before its id could be read. - No vacuous pass. A contract that governs no components, a component that requires no states, and an export with no stories each fail rather than reporting green on no evidence.
- Existence is not verification. A running story does not satisfy a state the contract listed
under
visualEvidence, and a snapshot recorded against a skipped story does not either. - Ordering is by UTF-16 code unit. Never
localeCompareorIntl.Collator, whose ICU data differs between Node builds. Pinned with inputs whose order genuinely differs between the two (Z.jsonbeforea.json;Z-story, a-b, a-story, a_b). - The clock is injected.
--now, orreadClockas a parameter. The instant actually used is recorded assummary.evaluatedAt, andsummary.clockInjectedsays whether it was pinned. - Every untrusted string is sanitised — component ids, story ids, state names, pointer segments, field names and excerpts alike. C0, DEL, C1, U+2028/U+2029 and the bidi controls are all removed, and a name that would render as nothing is refused rather than accepted and rendered blank.
- A value that cannot be stringified does not cost the report.
{"toString": {}}in any field, including one read before any schema check, still produces a report on stdout. - A parse failure never reproduces the document. V8 quotes the offending input back; the helper recognises the quoting shape before looking for an offset, and ends with a backstop that refuses any message still carrying a double quote.
- Path confinement is resolved, not lexical. A symbolic link inside the root that points outside it is refused; a root reached through a link still works.
- Determinism. The report is a pure function of the two documents and one evaluation instant.
Two runs over the same documents at the same instant produce byte-identical stdout, and the
instant is an input: pin it with
--nowand two machines agree on every byte. Without--nowthe host clock is read exactly once, at the start of the run, and the value it returned is written intosummary.evaluatedAt— so two default runs a second apart differ in that field, and in the age it implies, and in nothing else. That is the whole of the clock's reach, and it is stated here rather than in the CLI help alone, because a flat "byte-identical" was false for the default configuration.
| Limit | Default | Reaching it |
|---|---|---|
--max-document-bytes |
1048576 | incomplete |
--max-components |
500 | incomplete |
--max-stories |
20000 | incomplete |
--max-names-per-component |
50 | incomplete |
--max-findings |
1000 | incomplete, report partial |
--max-runtime-ms |
10000 | incomplete |
--max-age-hours |
72 | fail — a judgement about a document that was read |
Every limit is enforced and tested. An unknown limit or policy key throws rather than being ignored, and a flag that carries a value may be given once: a repeated flag is a configuration error, not a silent last-wins.
The time budget is checked between components, so it is not a hard deadline — a run overshoots by the cost of the component in hand.
- It does not render, screenshot, diff or measure anything. No browser, no story runner, no image. See What it does not do above.
- It does not decide whether a state is implemented correctly, only whether the export records a story for it.
- It does not read your story files. It reads an export of them. If the export is wrong or
stale, the report is wrong or stale, which is why
generatedis required and staleness fails. - It does not produce the export. Writing that exporter is a job for whatever tooling already knows how to enumerate stories; the shape it must produce is documented above.
- It does not write, move or delete files, and it has no auto-fix.
- It does not know about states outside the six it names. A contract requiring a seventh is refused, because a state nobody agrees on cannot be matched against an export somebody else wrote.
npm run check # lint + test + both examples + npm pack --dry-runMIT. See LICENSE.