Skip to content

feat: say what a run did not measure - #19

Merged
likeBloodMoon merged 14 commits into
masterfrom
claude/v0-3-0-roadmap-tzy5qc
Sep 1, 2026
Merged

feat: say what a run did not measure#19
likeBloodMoon merged 14 commits into
masterfrom
claude/v0-3-0-roadmap-tzy5qc

Conversation

@likeBloodMoon

Copy link
Copy Markdown
Owner

A crawl that fetched twelve pages of a two-hundred-page site produced a
report byte-identical to a complete one. The crawler had already counted
the failures, the truncation and how the pages were found; every one of
those numbers went to stderr and was then dropped on the floor.

That is the same false assurance this codebase refuses everywhere else.
passes is kept apart from inapplicable so an empty page cannot look
like a compliant one, and a rule the engine is blind to is reported as
unevaluated rather than as a pass. A clean verdict over a fraction of a
site is the same mistake, and it was the one still being made — to the
reader least able to catch it, since whoever opens the report is usually
not whoever watched it run.

So the facts reach the report now: a completeness object in JSON, a
section in the HTML report naming each page it could not reach and why,
lines in the console summary, and run.properties in SARIF. Pages that
were never fetched are counted apart from pages that were fetched and
then failed to audit, because they are different problems with different
fixes and summing them would name neither.

The HTML report grows a fourth verdict. A clean result over part of a
site no longer gets the plain pass banner: it says that no violations
were found and that the whole site was not measured, because the banner
is the part that gets quoted.

Exit codes are deliberately unchanged. An incomplete run is not a
violation, and failing builds on it would break every pipeline already
running this tool.

Also: one unreadable file no longer rejects the whole collection. A
single bad permission bit reported as a crash rather than as the one
page nobody could look at. The failure is only swallowed where a caller
has undertaken to report it, so nothing goes missing silently.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht

claude added 7 commits August 31, 2026 21:33
A crawl that fetched twelve pages of a two-hundred-page site produced a
report byte-identical to a complete one. The crawler had already counted
the failures, the truncation and how the pages were found; every one of
those numbers went to stderr and was then dropped on the floor.

That is the same false assurance this codebase refuses everywhere else.
`passes` is kept apart from `inapplicable` so an empty page cannot look
like a compliant one, and a rule the engine is blind to is reported as
unevaluated rather than as a pass. A clean verdict over a fraction of a
site is the same mistake, and it was the one still being made — to the
reader least able to catch it, since whoever opens the report is usually
not whoever watched it run.

So the facts reach the report now: a `completeness` object in JSON, a
section in the HTML report naming each page it could not reach and why,
lines in the console summary, and `run.properties` in SARIF. Pages that
were never fetched are counted apart from pages that were fetched and
then failed to audit, because they are different problems with different
fixes and summing them would name neither.

The HTML report grows a fourth verdict. A clean result over part of a
site no longer gets the plain pass banner: it says that no violations
were found and that the whole site was not measured, because the banner
is the part that gets quoted.

Exit codes are deliberately unchanged. An incomplete run is not a
violation, and failing builds on it would break every pipeline already
running this tool.

Also: one unreadable file no longer rejects the whole collection. A
single bad permission bit reported as a crash rather than as the one
page nobody could look at. The failure is only swallowed where a caller
has undertaken to report it, so nothing goes missing silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
The registry recognised fourteen builders; the route mapper knew five.
Everyone else — Remix, Gatsby, Docusaurus, VitePress, Starlight, Hugo —
got told their finding was in `leistungen.html`, which this module's own
doc comment calls accurate and unhelpful: nobody wrote that file.

Two changes made room for them.

Conventions each carry their own transform now, because the routers
agree on almost nothing: Remix delimits segments with dots and marks a
directory's own page `_index`, Hugo names a section index `_index.md`,
SvelteKit puts the route in the directory and the kind in the filename.
One function branching on the framework would have become a chain of
special cases nobody could read.

And the convention is chosen by what the project is rather than by which
directory exists. `src/pages` belongs to Next.js, Astro and Gatsby
alike, so probing in declaration order labelled every one of them
`next-pages`: the mapping was right and the name beside it was wrong,
which is the kind of detail a reader notices and stops trusting the rest
of the report over. Where the registry recognises nothing, the
directories are probed as before — most builds are not framework
projects, and a map is better than no map.

Angular, Eleventy and Jekyll stay unmapped on purpose, and the docs now
say why rather than leaving it looking like an oversight. Angular keeps
its routes in a TypeScript object, and reading it means parsing or
running project code — which `outputFromConfig` already refuses to do.
Eleventy and Jekyll let a page set its own URL in front matter, so their
file layout is not the route: mapping the default convention alone would
be right until somebody used the feature, and silently wrong after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
WordPress, TYPO3, Craft, Laravel, Symfony, Rails and Django render every
page per request and write no browsable HTML anywhere. Pointed at one,
the tool reported that ./dist held no HTML files — describing a
directory that was never going to hold any, and offering to fix a path
that was not wrong.

They are identified by a file rather than a dependency. A package.json
in one of these belongs to a theme's asset build and says nothing about
the CMS around it, which is also why they are ordered ahead of Vite: a
WordPress theme bundled with Vite is a WordPress site, and auditing the
folder Vite filled would audit its stylesheets.

Detected, and then left alone. `eaa-kit audit` with no arguments will
run a static builder's own build and even start its preview server; for
these it does neither. Starting one means spawning a stateful, usually
container-backed stack that may touch a database, which is a great deal
more than an accessibility audit was asked to do and not something to do
to somebody's machine uninvited — and `npm run build` here would produce
stylesheets and no pages. The guard runs before the package.json check,
because most Django and Rails projects have no package.json and the
ordinary path gives up there.

The registry invariant moves with it: an entry used to need an output
directory, and now needs somewhere to audit — an output directory, or
the ability to serve and a command that says how. An entry with neither
can be detected and never acted on, which is how the advice would come
to name a directory that cannot exist.

Also adds --sitemap. A CMS rarely puts its page list at /sitemap.xml:
WordPress with Yoast serves /sitemap_index.xml, TYPO3 and Craft put it
behind a route of their own, and link following alone then finds only
what the navigation happens to link to — on a site with a thousand
articles, a menu. A named sitemap replaces the default rather than
supplementing it, so a wrong path is a visible mistake rather than a
silent fall back to a crawl that quietly covers less.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
Neither report answers the question a review actually asks. Running the
auditor on a branch prints everything wrong with the site; almost all of
it was already there, and the two or three findings somebody introduced
are somewhere in the middle of it.

`eaa-kit diff before.json after.json` sorts every violating element into
new, fixed, unchanged — and not compared.

That fourth bucket is the reason to trust the other three. A violation
missing from the second run has two possible explanations: somebody
fixed it, or nothing looked at that page. They are not interchangeable,
and reporting the second as fixed would turn a crawl that stopped early
into a changelog of work nobody did — the same false assurance the
completeness block was just added to prevent. So a violation is called
fixed only where the later run actually reached a verdict on its page,
and the rest are listed apart with their pages named. A page that
errored counts as unmeasured too: it was reached and then not audited,
so it testifies to nothing.

Identity comes from elementFingerprint, the function the baseline and
SARIF already share, so all three agree on what one defect is. Markup
that changed reads as a different violation rather than the same one,
which is the conservative direction: it avoids calling something fixed
because its surroundings moved.

--fail-on judges new violations only. Failing on what was already there
would be an audit with extra steps, and would go red on every branch of
a site carrying any debt.

This is not a baseline and does not replace one. A baseline is a file
somebody commits and maintains, answering what we have agreed to live
with. A diff is stateless, needs no decision from anybody, and answers
what this change did.

Every node in the JSON report now carries its fingerprint, so a consumer
comparing two reports need not reimplement the hash and risk disagreeing
with the two tools that already depend on it agreeing. schema.ts gains
boolean(), which its own note invites: it does what these schemas need
and no more.

Also fixes the two stale pins the roadmap turned up: the Action example
in the docs named a release three versions old, and the runnable
workflow told readers to use a moving @v0 tag that the same docs page
says does not and will not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
The tool refuses to divide passes by failures, and it is right to: only
`passes` is evidence, `inapplicable` is not, and the two summed would
make an empty page look compliant. But refusing that number left nothing
in its place, so a reader got a page of honest disclaimers and no
measurement at all, and "automated testing finds a minority of barriers"
stayed a sentence in the footer rather than a figure.

There is an honest denominator and it is the standard, not the DOM.
WCAG 2.2 has 55 success criteria at Levels A and AA. axe-core has rules
touching 23 of them. Every run says so in a line, --coverage lists all
55 and what the run reached on each, and the HTML report always carries
the table.

Four outcomes, and every criterion lands in exactly one — the same
discipline shapeResults enforces for rules, for the same reason.
`nothing-to-check` is kept apart from `evaluated` because a rule that
ran and matched nothing has not shown the criterion is met: a page with
no images proves nothing about image alternatives, which is precisely
why `inapplicable` is already kept out of `passes`.

They are never summed or divided. `no-automated-rule` is the majority of
WCAG, so a percentage would present a limit of automated testing as
though it were a measurement of somebody's site — a nicer-looking number
than the truth and a worse one. Tests assert the partition holds, that
the majority stays unautomatable, and that no output contains a percent
sign.

The engine gap falls out of the same table. MANUAL_CHECKS already
recorded, per rule, whether a real browser resolves it; nothing had ever
read that field. The report now says how many more criteria --browser
would answer, which replaces general advice with a count.

ENGINE_BLIND_RULES and blindRulesInScope move to result.ts, beside the
BlindRule type they instantiate. They describe what an engine can see
rather than the jsdom library, and coverage.ts is reachable from the
CLI's startup path — reading them where they were would have pulled
630 ms of jsdom into `eaa-kit --help`. Both are still exported from the
runner, so nothing that imported them had to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
axe-core says "images must have alternative text" and links to a page
about the rule. Neither is the fix. The first tells somebody who already
knows nothing they did not know, and the gap between a report and a
corrected line of code is where an accessibility tool either earns its
place in a build or becomes something people mute.

Each finding now carries who the barrier stops, what to change, and the
corrected form of the markup that actually failed — the user's own
element, because a textbook snippet is a second thing to translate
before anybody can use it.

Where the fix genuinely differs by framework it is given in that idiom:
where `lang` lives in a Next.js, Nuxt, Astro, SvelteKit or Remix project
is a different question from which attribute is missing. Where it does
not differ, which is most rules, the same advice is given whatever built
the site — a missing alt is a missing alt in all fourteen builders the
registry knows, and emitting fourteen near-identical snippets would be
churn pretending to be coverage. An overlay states only what changes and
inherits the rest, so a framework-specific fix cannot quietly drop the
corrected snippet and the sentence about who is shut out along with it.

Deterministic and offline: no model, no API key, no network. This sits
beside a document with legal weight, and a plausible fix that is wrong
is a worse failure here than no fix at all — somebody would paste it,
the report would go green, and the barrier would still be there. A fixed
table is reviewed once and then trusted; a generated one has to be
reviewed every time, by the person least equipped to.

componentFor now returns a location rather than a path. It already had
to find the literal in the source to know which file contained it, so
recording where it found it costs one more scan of one string — and
`src/components/Header.astro:12` opens an editor where
`src/components/Header.astro` starts a search. This is also what the PR
annotations deferred to 0.5.0 will need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
Four more ways to run the audit as part of a build. Each is a thin
adapter over the decision function the Astro integration and the Vite
plugin already share, and each describes its host structurally, so none
of them adds a dependency: installing eaa-kit in a project with no
webpack still typechecks.

Two of them earn their place by knowing something the Vite plugin
cannot.

The Nuxt module audits in `close`, not when Vite finishes. Nuxt builds
on Vite, so `eaa-kit/vite` already runs there — and it runs too early:
Nitro prerenders after Vite is done, so the hook fires before the pages
the audit exists to read have been written. It also refuses to pass a
`nuxt build`, which produces a server and no browsable HTML: auditing
nothing and reporting success is the worst outcome available here, so a
run that prerendered no pages says so instead.

The webpack plugin skips watch rebuilds — auditing a whole site on every
keystroke would make a dev server unusable, and a failing audit there
cannot stop anything being shipped — and skips a build that already
failed, because it has no output worth judging and reporting its missing
pages as accessibility findings would send somebody after defects
nothing ever measured.

Eleventy and Docusaurus are the straightforward two. Both hand over the
directory they wrote, which matters for Eleventy in particular: its
output directory is configurable and the audit would otherwise be
guessing at `_site`.

There is deliberately no Next.js plugin, and the docs say why rather
than leaving it looking like an oversight. Next has no hook that fires
after `output: 'export'` writes `out/` — webpack's compiler hooks all
run before it — so a `withEaaKit()` wrapper would audit stale output or
none at all, and a plugin that silently audits the previous build is
worse than having none.

The four copies of the stderr logger these needed became one in
integration/run.ts, beside the interface it satisfies.

The packaged harness now imports every subpath export from a real
install rather than checking the file exists. A file that ships and does
not load fails inside somebody's build rather than here, which is how
the browser-mode bugs of 0.2.x reached users in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

They failed on windows-latest. The guard asked whether the tests were
running as root, and root is only one of the two environments where a
file chmodded to 000 stays readable — Windows is the other, because its
chmod toggles a read-only flag that does not stop a read.

Worse, the guard could not even see it: `process.getuid` does not exist
on Windows, so `process.getuid?.() === 0` was `undefined === 0`, the
cases ran, and they asserted against a file the process opened without
complaint.

Naming environments was the mistake, so the precondition is measured
instead: write a file, deny it, try to read it. That is exactly the
thing the cases need to be true, it covers root and Windows without
mentioning either, and it stays right on a platform nobody thought
about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
It did neither. The line chained `&&` onto an audit of
tests/fixtures/impacts, which exits 1 by design because those fixtures
carry serious violations — so the chain stopped there and `diff` was
never invoked. The trailing `; test $? -le 1` then turned that into a
pass. The check reported green on every platform while exercising
nothing, which is worse than not having it.

Windows only made it visible: `test` is not a command under cmd.exe, and
`/tmp` is not a path there either.

Both reports now come from `--include about/**`, the clean subset the
rest of this script already uses, so each audit exits 0 and the chain
reaches the end. Comparing them exercises the whole path — read two
reports, compare, serialise — and exits 0 because nothing changed
between them. What diff decides is asserted in its own suite; what this
has to prove is that the command runs at all in a built package.

The scratch reports go to .eaa-kit/, which is already gitignored and is
the tool's own namespace, rather than to a hardcoded /tmp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
WCAG 2.1.1 Keyboard is the only criterion whose rules are mixed:
scrollable-region-focusable needs computed overflow, while
frame-focusable-content and server-side-image-map are DOM-determinable.

Requiring every rule to be blind before saying "not evaluated" meant a
page with no frames and no image maps fell through to "rules ran and
found nothing on this site to check" — while the same report's rule
listing said, four sections earlier, that scrollable-region-focusable
had reached no verdict on all five pages.

One document contradicting itself about a Level A criterion, and in the
direction that flatters the tool: the browserless engine's cost was
reported as one criterion lower than it is. On the fixture site
`--browser would answer` was 4 and is 5.

The rule is now what it should always have been. `nothing-to-check` is
reserved for a criterion where every rule ran and matched nothing;
anything the engine could not see puts it in `not-evaluated`, where the
rule listing already had it.

A test asserts the two views cannot drift apart again: every criterion
carrying a rule reported as an engine limitation must read as unevaluated
in the coverage table too.

Also adds the drift test the third copy of the report schema version
never had. json.ts writes it, the statement reader asserts it matches,
and diff.ts added a third that nothing checked — so a bump would have
left `diff` quietly refusing every report the tool writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
It took the output directory from `nuxt.options.nitro.output.publicDir`.
Nuxt never populates that — Nitro resolves the output onto its own
instance — so the module would have refused every build it was wired
into, `nuxt generate` included, with "there was nothing to audit".

Its unit test agreed with the bug. A hand-written stand-in confirms
whatever its author assumed, and I had assumed wrong in both places at
once, so the two matched and the suite went green over a module that
could never work.

It is now driven by a real `nuxt generate` and a real `nuxt build`, the
way the Astro integration already was, and nuxt is a devDependency for
it. The directory comes from `nitro:init`, where Nitro actually puts it,
and a server build is told apart from a static one by
`nitro.options.static` rather than by guesswork.

The timing claim the module exists for survived the check: at
`build:done` the public directory does not exist at all, and by `close`
it holds every prerendered page. Auditing in `close` is right, and the
Vite plugin genuinely would have run too early.

Two things the fixture taught, which are worth knowing: Nuxt emits
neither a lang attribute nor a title by default, and both are serious
violations on every page it renders — the "clean" fixture needed both
before these cases stopped passing for the wrong reason.

Also in this commit:

- 0.4.0, and the changelog dated.
- The Action gains a `sitemap` input. CMS support is a headline of this
  release and the Action is how the audience it is aimed at would run
  it; without this the flag was unreachable from CI.
- The README says what the release does. Coverage, remediation and four
  of the six integrations were invisible in it, and the line listing
  where it runs was stale.
- A duplicate "Not Next.js" section removed from the integration docs.
  One was already there, with better reasoning than mine: Next 16
  defaults to Turbopack, which never calls the webpack function a
  plugin would have to hook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
After the Nuxt module turned out to read a field that does not exist,
leaving the others on trust was not defensible. Eleventy is cheap to
install and builds in under a second, so there was no reason to.

Its reading of the hook was right: `eleventy.after` does hand over the
resolved output directory, and one of the cases points the project at a
directory that is not `_site` to prove the plugin uses what it is given
rather than a guess that happens to match the default.

The build did surface something else. `eleventy.after` fires on every
rebuild under --watch and --serve, so a dev server would have run a full
site audit between keystrokes. The webpack plugin already refused that
and Eleventy did not, for no reason other than that I wrote the guard in
one file and not the other. It now audits one-shot builds only, and
skips the output modes that write nothing to disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
The generated eleventy.config.mjs did `import eaaKit from "D:\a\..."`.
Eleventy loads that config with import(), and a bare Windows absolute
path is not a valid ESM specifier — Node rejects it as an unsupported
'd:' scheme, so the config failed to load and every case in the file
went red on windows-latest.

The Astro fixture has resolved this the right way since it was written,
with pathToFileURL, and says why in a comment two lines long. The Nuxt
fixture is unaffected: Nuxt resolves module paths itself rather than
importing them as specifiers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
…once

`nuxt generate` failed in CI with "the module dist/nuxt/index.js could
not be loaded". The module was there when the fixture was written and
gone by the time Nuxt resolved it.

Three suites drive a real build of somebody else's tool — Astro,
Eleventy, Nuxt — and each has to load this package's built entry point,
because that is what a consumer loads. CI runs `pnpm test` before
`pnpm smoke`, so dist/ does not exist yet and something has to make it.
Each of them made it, with the same beforeAll copied between them.

That was fine while Astro was the only one. With three, vitest runs the
files in parallel workers, all three find dist/ missing at the same
moment, and all three spawn tsdown — which is configured `clean: true`
and so deletes the directory the other two are reading.

It never failed on this machine because dist/ was already built here, so
the `access` check short-circuited and nothing ever entered the race.
That is also why removing dist/ locally does not reproduce it reliably:
the window is real but narrow, and a shared CI runner is slower and
busier than a laptop. The mechanism is not in doubt — the CI log shows
the file resolving and then not existing — so this is fixed by removing
the hazard rather than by trying to lose the race again.

The build moves to a vitest globalSetup, which runs once in the main
process before any worker starts. Three copies of the beforeAll go away
with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HKU933TcRaKinharQxrNht
@likeBloodMoon
likeBloodMoon merged commit 0a067fc into master Sep 1, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants