bfsg-scanner 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +29 -0
- package/dist/cli/run.js +202 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.js +3 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/load.d.ts +12 -0
- package/dist/config/load.js +47 -0
- package/dist/config/load.js.map +1 -0
- package/dist/config/schema.d.ts +51 -0
- package/dist/config/schema.js +16 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/discovery/crawl.d.ts +21 -0
- package/dist/discovery/crawl.js +79 -0
- package/dist/discovery/crawl.js.map +1 -0
- package/dist/discovery/discover-site.d.ts +26 -0
- package/dist/discovery/discover-site.js +50 -0
- package/dist/discovery/discover-site.js.map +1 -0
- package/dist/discovery/discover.d.ts +25 -0
- package/dist/discovery/discover.js +81 -0
- package/dist/discovery/discover.js.map +1 -0
- package/dist/discovery/index.d.ts +11 -0
- package/dist/discovery/index.js +7 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/discovery/links.d.ts +11 -0
- package/dist/discovery/links.js +69 -0
- package/dist/discovery/links.js.map +1 -0
- package/dist/discovery/robots.d.ts +18 -0
- package/dist/discovery/robots.js +91 -0
- package/dist/discovery/robots.js.map +1 -0
- package/dist/discovery/sitemap.d.ts +18 -0
- package/dist/discovery/sitemap.js +36 -0
- package/dist/discovery/sitemap.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/report/build.d.ts +14 -0
- package/dist/report/build.js +100 -0
- package/dist/report/build.js.map +1 -0
- package/dist/report/clauses.d.ts +32 -0
- package/dist/report/clauses.js +57 -0
- package/dist/report/clauses.js.map +1 -0
- package/dist/report/i18n.d.ts +54 -0
- package/dist/report/i18n.js +95 -0
- package/dist/report/i18n.js.map +1 -0
- package/dist/report/index.d.ts +12 -0
- package/dist/report/index.js +8 -0
- package/dist/report/index.js.map +1 -0
- package/dist/report/pdf.d.ts +12 -0
- package/dist/report/pdf.js +25 -0
- package/dist/report/pdf.js.map +1 -0
- package/dist/report/render.d.ts +8 -0
- package/dist/report/render.js +204 -0
- package/dist/report/render.js.map +1 -0
- package/dist/report/types.d.ts +78 -0
- package/dist/report/types.js +2 -0
- package/dist/report/types.js.map +1 -0
- package/dist/report/verdict.d.ts +20 -0
- package/dist/report/verdict.js +28 -0
- package/dist/report/verdict.js.map +1 -0
- package/dist/report/write.d.ts +21 -0
- package/dist/report/write.js +35 -0
- package/dist/report/write.js.map +1 -0
- package/dist/scan/index.d.ts +2 -0
- package/dist/scan/index.js +2 -0
- package/dist/scan/index.js.map +1 -0
- package/dist/scan/scan.d.ts +2 -0
- package/dist/scan/scan.js +82 -0
- package/dist/scan/scan.js.map +1 -0
- package/dist/scan/types.d.ts +56 -0
- package/dist/scan/types.js +2 -0
- package/dist/scan/types.js.map +1 -0
- package/dist/shared/index.d.ts +4 -0
- package/dist/shared/index.js +5 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/shared/package-info.d.ts +10 -0
- package/dist/shared/package-info.js +11 -0
- package/dist/shared/package-info.js.map +1 -0
- package/dist/shared/pool.d.ts +7 -0
- package/dist/shared/pool.js +25 -0
- package/dist/shared/pool.js.map +1 -0
- package/dist/shared/rate-limiter.d.ts +11 -0
- package/dist/shared/rate-limiter.js +26 -0
- package/dist/shared/rate-limiter.js.map +1 -0
- package/dist/shared/user-agent.d.ts +11 -0
- package/dist/shared/user-agent.js +12 -0
- package/dist/shared/user-agent.js.map +1 -0
- package/docs/SCANNING-ETHICS.md +52 -0
- package/docs/adr/0001-page-discovery-strategy.md +74 -0
- package/docs/adr/0002-json-report-output.md +54 -0
- package/docs/adr/0003-failon-threshold-and-exit-codes.md +72 -0
- package/docs/adr/0004-clause-mapping-from-axe-tags.md +81 -0
- package/docs/adr/0005-html-report-rendering.md +71 -0
- package/docs/adr/0006-pdf-report-rendering.md +58 -0
- package/docs/adr/0007-cli-argument-surface.md +73 -0
- package/docs/adr/0008-report-json-schema.md +59 -0
- package/package.json +73 -0
- package/schema/report.v1.json +156 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# 1. Page discovery strategy
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-26
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
The scanner is given a single `baseUrl`. Before it can run axe-core it needs the
|
|
12
|
+
list of pages to visit. Two sources are available for a typical site:
|
|
13
|
+
|
|
14
|
+
- **`sitemap.xml`** (optionally a sitemap index pointing at sub-sitemaps) — an
|
|
15
|
+
explicit, machine-readable list published by the site owner.
|
|
16
|
+
- **Crawling** — following same-origin `<a href>` links breadth-first from
|
|
17
|
+
`baseUrl`.
|
|
18
|
+
|
|
19
|
+
Both were already implemented as standalone modules (`discoverFromSitemap`,
|
|
20
|
+
`crawlSite`) but nothing combined them, and the CLI scanned only `[baseUrl]`,
|
|
21
|
+
ignoring `maxPages` and `excludePaths`.
|
|
22
|
+
|
|
23
|
+
Constraints:
|
|
24
|
+
|
|
25
|
+
- A compliance report should reflect the whole site, not just the home page.
|
|
26
|
+
- Runs must stay bounded (`maxPages`) and polite (per-host rate limiting already
|
|
27
|
+
exists in the scan engine; `robots.txt` is respected by the crawler).
|
|
28
|
+
- Output must be deterministic so the golden e2e tests stay stable.
|
|
29
|
+
|
|
30
|
+
## Decision
|
|
31
|
+
|
|
32
|
+
Add a thin orchestrator, `discoverSite`, that picks one strategy:
|
|
33
|
+
|
|
34
|
+
1. **Sitemap first.** Fetch `/sitemap.xml`, follow an index to its
|
|
35
|
+
sub-sitemaps, collect same-origin `<loc>` entries. The sitemap is the
|
|
36
|
+
owner's own declaration of what exists, so it is preferred whenever present.
|
|
37
|
+
2. **Crawl as fallback.** If the root sitemap is missing or unparseable
|
|
38
|
+
(`SitemapError`), fall back to `crawlSite` from `baseUrl`.
|
|
39
|
+
|
|
40
|
+
Shared rules across both strategies:
|
|
41
|
+
|
|
42
|
+
- `excludePaths` globs and the non-page-asset filter (`.pdf`, `.jpg`, …) are
|
|
43
|
+
applied identically. `discoverFromSitemap` gained an `excludePaths` option in
|
|
44
|
+
this change so the two paths cannot diverge.
|
|
45
|
+
- The result is capped at `maxPages` and stays in discovery order.
|
|
46
|
+
|
|
47
|
+
Where they deliberately differ:
|
|
48
|
+
|
|
49
|
+
- **`robots.txt`** is obeyed on the crawl path only. A URL the owner placed in
|
|
50
|
+
their own sitemap is taken as intended for scanning; `robots.txt` is a
|
|
51
|
+
crawl-politeness directive, and on the sitemap path there is no crawl.
|
|
52
|
+
- **`baseUrl` guarantee**: on the sitemap path `baseUrl` is prepended if the
|
|
53
|
+
sitemap did not list it, so the home page is always covered. The crawl path
|
|
54
|
+
already starts from `baseUrl` and needs no fixup — including the case where a
|
|
55
|
+
`robots.txt`-disallowed home page correctly yields zero pages.
|
|
56
|
+
|
|
57
|
+
The CLI now calls `discoverSite` and exits `3` when it returns no pages
|
|
58
|
+
(previously `3` meant only "a page failed to load").
|
|
59
|
+
|
|
60
|
+
## Consequences
|
|
61
|
+
|
|
62
|
+
- The `discovery/` modules are now reachable from the CLI; `maxPages` and
|
|
63
|
+
`excludePaths` take effect for the first time.
|
|
64
|
+
- A site with a stale or partial sitemap is scanned per that sitemap, not
|
|
65
|
+
crawled to fill the gaps. Merging both sources is possible later but adds
|
|
66
|
+
non-determinism and crawl cost; deferred until there is a real need.
|
|
67
|
+
- URL normalisation still differs slightly between the two paths
|
|
68
|
+
(`crawlSite` strips fragments and normalises; `discoverFromSitemap` emits
|
|
69
|
+
`<loc>` verbatim). `discoverSite` compares normalised forms when deciding
|
|
70
|
+
whether to prepend `baseUrl`, but a fuller normalisation pass is left for a
|
|
71
|
+
follow-up.
|
|
72
|
+
- `robots.txt` being ignored on the sitemap path is a conscious trade-off; if
|
|
73
|
+
it proves surprising it can be revisited without changing the module
|
|
74
|
+
boundaries.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# 2. JSON report output
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-27
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
Discovery (ADR 0001) and the scan engine now produce a full `ScanResult`, but
|
|
12
|
+
the CLI only prints a one-line summary — nothing is persisted. A compliance
|
|
13
|
+
tool has to leave an artifact: something a CI job can archive, a diff can run
|
|
14
|
+
against, and a human or a later HTML/PDF renderer can consume.
|
|
15
|
+
|
|
16
|
+
`src/index.ts` already declares `SCHEMA_VERSION = 1` for exactly this, and the
|
|
17
|
+
config already has `outputDir` (default `reports/`, git-ignored).
|
|
18
|
+
|
|
19
|
+
## Decision
|
|
20
|
+
|
|
21
|
+
Add a `report/` module with three pieces:
|
|
22
|
+
|
|
23
|
+
- **`types.ts`** — the `Report` shape: `schemaVersion`, `generatedAt`, `tool`
|
|
24
|
+
(name/version), `target` (the `baseUrl` / `wcagTags` / `failOn` echoed back
|
|
25
|
+
so a stored report is self-describing), a `summary`, and `pages`.
|
|
26
|
+
- **`buildReport(scanResult, target, options?)`** — pure apart from a default
|
|
27
|
+
clock and a one-time `package.json` read, both overridable via `options`
|
|
28
|
+
(`generatedAt`, `tool`) for deterministic tests. Computes the summary:
|
|
29
|
+
pages scanned/failed/with-violations, total violated rules, and rule counts
|
|
30
|
+
bucketed by axe-core impact (`critical|serious|moderate|minor|unknown`,
|
|
31
|
+
where `unknown` is impact `null`).
|
|
32
|
+
- **`writeReport(report, outputDir)`** — `mkdir -p`, then write
|
|
33
|
+
`<outputDir>/report.json` as pretty JSON with a trailing newline; returns
|
|
34
|
+
the path.
|
|
35
|
+
|
|
36
|
+
The CLI calls both after the scan, **before** the partial-failure check, so a
|
|
37
|
+
run that exits `3` for unreachable pages still leaves a report — the partial
|
|
38
|
+
results and the failure entries are worth keeping.
|
|
39
|
+
|
|
40
|
+
## Consequences
|
|
41
|
+
|
|
42
|
+
- `pages` is the scan engine's `PageScanResult[]` verbatim (failures
|
|
43
|
+
included). One source of truth for the result shape; no parallel type
|
|
44
|
+
hierarchy to keep in sync. The trade-off: the on-disk schema is now
|
|
45
|
+
coupled to `scan/types.ts`, so a change there is a `schemaVersion` bump.
|
|
46
|
+
- Node HTML and selectors from axe are written in full. That is the actual
|
|
47
|
+
evidence for each violation; `maxPages` bounds the total size.
|
|
48
|
+
- One canonical `report.json`, overwritten each run — predictable for CI.
|
|
49
|
+
Timestamped history, and non-JSON renderings (HTML, PDF), are deliberately
|
|
50
|
+
out of scope here.
|
|
51
|
+
- `generatedAt` makes the file non-byte-deterministic; that is fine for an
|
|
52
|
+
artifact (it is not a golden fixture) and tests inject a fixed clock.
|
|
53
|
+
- `failOn` is carried in the report but not yet acted on — scoring and a
|
|
54
|
+
threshold exit code are the next change.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# 3. failOn threshold and exit codes
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-27
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
`failOn` (`critical | serious | moderate | minor`, default `serious`) has
|
|
12
|
+
been in the config since ADR 0002's predecessor, and ADR 0002 carried it into
|
|
13
|
+
the report — but nothing acted on it. Every completed scan exited `0`, so no
|
|
14
|
+
CI job could gate a merge on accessibility, which is the whole point of a
|
|
15
|
+
compliance scanner in a pipeline.
|
|
16
|
+
|
|
17
|
+
The existing exit codes were `0` (completed), `2` (bad config), `3` (nothing
|
|
18
|
+
discovered, or a page failed to scan).
|
|
19
|
+
|
|
20
|
+
## Decision
|
|
21
|
+
|
|
22
|
+
**Severity ranking.** axe-core impacts rank `minor < moderate < serious <
|
|
23
|
+
critical`. A violation meets the threshold when its impact is at least as
|
|
24
|
+
severe as `failOn`.
|
|
25
|
+
|
|
26
|
+
**Unranked violations.** A violation whose impact axe left `null` — or one
|
|
27
|
+
carrying an impact string a future axe version might introduce — is never
|
|
28
|
+
counted toward the threshold. It cannot be ranked, so it can neither be
|
|
29
|
+
trusted to clear a threshold nor be assumed to breach one. Guessing in
|
|
30
|
+
either direction is worse than saying so: such findings are counted in the
|
|
31
|
+
report's `verdict.unrankedViolations`, and the CLI prints a warning naming
|
|
32
|
+
them. They are excluded from the pass/fail arithmetic, never from view.
|
|
33
|
+
|
|
34
|
+
**Verdict scope.** The verdict answers only "did the pages that scanned stay
|
|
35
|
+
under the threshold?". A page that failed to load has *unknown* compliance:
|
|
36
|
+
it is counted in `summary.pagesFailed` and drives the exit code, but it
|
|
37
|
+
cannot flip a verdict in either direction.
|
|
38
|
+
|
|
39
|
+
**Exit codes.**
|
|
40
|
+
|
|
41
|
+
| Code | Meaning |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `0` | scan completed, nothing at or above `failOn` |
|
|
44
|
+
| `1` | scan completed, violations at or above `failOn` |
|
|
45
|
+
| `2` | invalid or missing configuration |
|
|
46
|
+
| `3` | no pages discovered, or a page could not be scanned |
|
|
47
|
+
|
|
48
|
+
`3` outranks `1`. A run with unreachable pages scanned an incomplete site,
|
|
49
|
+
so reporting "no violations found" would be a claim the data cannot support.
|
|
50
|
+
The report is written before either check, so partial results and failure
|
|
51
|
+
entries survive regardless of which code is returned.
|
|
52
|
+
|
|
53
|
+
`1` is the conventional "ran fine, found problems" code (`grep`, `eslint`,
|
|
54
|
+
`tsc`), which is what a CI gate expects.
|
|
55
|
+
|
|
56
|
+
## Consequences
|
|
57
|
+
|
|
58
|
+
- Adding `verdict` to the report is additive, so `schemaVersion` stays `1`:
|
|
59
|
+
nothing consumes a published report yet and no report from an earlier
|
|
60
|
+
build exists in the wild. The version starts mattering at the first
|
|
61
|
+
release.
|
|
62
|
+
- `target.failOn` is now the `ImpactThreshold` union rather than a bare
|
|
63
|
+
`string`, so an unrankable threshold cannot reach the report at all.
|
|
64
|
+
- The default `failOn: serious` means a `color-contrast` finding (impact
|
|
65
|
+
`serious`) fails a build. That matches the BFSG/EN 301 549 baseline, where
|
|
66
|
+
contrast is a genuine conformance failure rather than an advisory.
|
|
67
|
+
- Counting is by **violated rule**, not by node. Ten images missing `alt` on
|
|
68
|
+
one page is one `image-alt` violation. Node-level counts stay available in
|
|
69
|
+
the report for anyone who needs to weigh breadth.
|
|
70
|
+
- Not addressed here: per-rule ignores/waivers, and a budget ("fail only
|
|
71
|
+
above N violations"). Both are real needs for a team adopting the scanner
|
|
72
|
+
on an existing site, but neither belongs in the first threshold change.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# 4. WCAG / EN 301 549 clause mapping from axe tags
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-28
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
The report so far names each finding only by its axe `ruleId` (`color-contrast`,
|
|
12
|
+
`image-alt`). A BFSG / EN 301 549 report has to say which clause of the standard
|
|
13
|
+
a finding breaches — that citation is the compliance value the tool exists to
|
|
14
|
+
produce. Something has to turn `color-contrast` into "WCAG SC 1.4.3 / EN 301 549
|
|
15
|
+
clause 9.1.4.3".
|
|
16
|
+
|
|
17
|
+
Two ways to get there:
|
|
18
|
+
|
|
19
|
+
1. **Hand-author a table** `ruleId → clauses` in this repo.
|
|
20
|
+
2. **Read it from axe-core.** Every rule's metadata already carries a `tags`
|
|
21
|
+
array, and the tags encode the mapping: `color-contrast` is tagged
|
|
22
|
+
`wcag143` (WCAG SC 1.4.3) and `EN-9.1.4.3` (EN 301 549 clause 9.1.4.3);
|
|
23
|
+
`wcag2aa` marks the conformance level. Verified against `axe-core@4.13.0`:
|
|
24
|
+
69 rules carry an `EN-301-549` tag, and every rule in the scanner's default
|
|
25
|
+
rule set that maps to a success criterion carries a `wcag<digits>` tag for it.
|
|
26
|
+
|
|
27
|
+
## Decision
|
|
28
|
+
|
|
29
|
+
**Read the mapping from the rule tags.** A hand-authored table is a second
|
|
30
|
+
source of truth for something axe already states, drifts on every axe upgrade,
|
|
31
|
+
and is exactly the kind of transcription that ships wrong. The scan layer now
|
|
32
|
+
carries each finding's `tags` verbatim (`ScanFinding.tags`), and a pure
|
|
33
|
+
function `clausesFor(tags)` in `src/report/clauses.ts` derives:
|
|
34
|
+
|
|
35
|
+
- **WCAG success criteria** from `^wcag(\d)(\d)(\d+)$` — first two digits are
|
|
36
|
+
the principle and guideline, the rest is the criterion: `wcag143` → `1.4.3`,
|
|
37
|
+
`wcag1410` → `1.4.10`. Level tags (`wcag2a`, `wcag2aa`, `wcag21aa`) keep
|
|
38
|
+
letters after `wcag` and never match.
|
|
39
|
+
- **EN 301 549 clauses** from `^EN-(\d+(?:\.\d+)+)$`: `EN-9.1.4.3` → `9.1.4.3`.
|
|
40
|
+
The bare `EN-301-549` marker uses hyphens, not dots, so it is skipped — it
|
|
41
|
+
says a rule maps to the standard, not to which clause.
|
|
42
|
+
|
|
43
|
+
Everything else — `cat.*`, `ACT`, `TTv5`, `section508*`, `RGAA*`,
|
|
44
|
+
`best-practice`, `experimental` — is ignored. Results are deduped and sorted
|
|
45
|
+
numerically (`1.4.10` after `1.4.3`).
|
|
46
|
+
|
|
47
|
+
**Where clauses live in the report.** `buildReport` attaches
|
|
48
|
+
`clauses: { wcagSc, en301549 }` to every finding (violations and incomplete
|
|
49
|
+
alike), and `summary` gains `breachedSuccessCriteria` and
|
|
50
|
+
`breachedEn301549Clauses` — the distinct clauses across all *violations*.
|
|
51
|
+
Incomplete results are excluded from that rollup: they are unconfirmed, and a
|
|
52
|
+
rollup is a statement that the site breaches these clauses. The report's
|
|
53
|
+
`pages` are therefore no longer the scan pages verbatim; the findings inside
|
|
54
|
+
are enriched, the failure entries pass through unchanged.
|
|
55
|
+
|
|
56
|
+
**BFSG.** The BFSG names no clauses of its own for web content. It grants a
|
|
57
|
+
presumption of conformity (BFSG § 3 (1), § 4) to anything meeting the
|
|
58
|
+
harmonised standard, and for ICT that standard is EN 301 549, whose Chapter 9
|
|
59
|
+
("Web") is WCAG 2.1 level A/AA. So the EN 301 549 clauses this mapping emits
|
|
60
|
+
*are* the BFSG-relevant citations; there is no separate BFSG layer to model,
|
|
61
|
+
and inventing paragraph numbers for one would be worse than nothing. The legal
|
|
62
|
+
chain (BFSG § 3/§ 4 → EN 301 549 § 9 → WCAG 2.1) is documentation, recorded
|
|
63
|
+
here and in the README, not report data.
|
|
64
|
+
|
|
65
|
+
## Consequences
|
|
66
|
+
|
|
67
|
+
- `schemaVersion` stays `1`: `tags` on findings, `clauses` on findings, and the
|
|
68
|
+
two `summary` arrays are all additive, and nothing consumes a published
|
|
69
|
+
report yet. As ADR 0003 already noted, the version starts mattering at the
|
|
70
|
+
first release — that release freezes this shape.
|
|
71
|
+
- The mapping tracks axe-core. An axe upgrade that renames a tag or corrects a
|
|
72
|
+
rule's criteria changes the scanner's output with no code change here. The
|
|
73
|
+
golden e2e test (`src/scan/scan.e2e.test.ts`) pins the clauses for the five
|
|
74
|
+
fixture rules against `fixtures/SITE_TRUTH.md`, so such a change fails CI and
|
|
75
|
+
gets reviewed rather than shipping silently.
|
|
76
|
+
- A rule that carries no `wcag<digits>` / `EN-` tag (a `best-practice` rule)
|
|
77
|
+
contributes an empty `clauses` and nothing to the rollup. With the default
|
|
78
|
+
WCAG rule set none such fire, but a custom `wcagTags` could pull one in; the
|
|
79
|
+
finding still appears in the report, just without a citation.
|
|
80
|
+
- `clausesFor` is independent of axe at runtime — it takes a `string[]`. Tests
|
|
81
|
+
feed it the real recorded tag arrays rather than calling into axe.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# 5. HTML report rendering
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-28
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
`bfsg.config.yaml` has always advertised JSON, HTML and PDF output and a
|
|
12
|
+
`reportLanguage: de | en` setting, but only JSON was written and
|
|
13
|
+
`reportLanguage` was loaded and then ignored. The JSON report (ADR 0002) is
|
|
14
|
+
the machine artifact; a compliance run also needs something a person —
|
|
15
|
+
often a non-engineer acting on the BFSG obligation — can open and read, in
|
|
16
|
+
German by default.
|
|
17
|
+
|
|
18
|
+
## Decision
|
|
19
|
+
|
|
20
|
+
Add `renderHtmlReport(report, locale)` (`src/report/render.ts`) and
|
|
21
|
+
`writeHtmlReport(html, outputDir)` (`src/report/write.ts`). The CLI writes
|
|
22
|
+
`<outputDir>/report.html` right after `report.json`, using
|
|
23
|
+
`config.reportLanguage`.
|
|
24
|
+
|
|
25
|
+
**Self-contained single file.** One HTML document with an inline `<style>`,
|
|
26
|
+
no JavaScript, and no external assets. It has to survive being archived as a
|
|
27
|
+
CI artifact, opened offline, emailed, and — next change — printed to PDF by
|
|
28
|
+
a headless browser. A build step or an asset bundle would work against all
|
|
29
|
+
of that.
|
|
30
|
+
|
|
31
|
+
**Hand-written string table, not an i18n library** (`src/report/i18n.ts`).
|
|
32
|
+
Two locales, ~40 keys, chosen once from config with no runtime negotiation.
|
|
33
|
+
Every label is phrased to sidestep grammatical agreement
|
|
34
|
+
(`Affected elements: 3`, not `3 elements`), so ICU plural handling buys
|
|
35
|
+
nothing. A dependency here would be more API surface and supply chain than
|
|
36
|
+
the problem has.
|
|
37
|
+
|
|
38
|
+
**The JSON stays language-neutral.** A locale is a rendering argument, not
|
|
39
|
+
report data: the same scan produces one `report.json` and can be rendered to
|
|
40
|
+
either language. `Report` is unchanged, so `schemaVersion` is untouched —
|
|
41
|
+
HTML is not part of the schema.
|
|
42
|
+
|
|
43
|
+
**What the page leads with.** Verdict banner, then the summary, then the
|
|
44
|
+
breached WCAG / EN 301 549 clauses from the scan (ADR 0004) as the headline
|
|
45
|
+
compliance statement, then findings per page with the rule, impact, its
|
|
46
|
+
clauses, the fix link, and the offending node markup as evidence. All
|
|
47
|
+
interpolated text — URLs, rule evidence, error strings — is HTML-escaped;
|
|
48
|
+
node HTML from axe is attacker-influenced (it is markup from the scanned
|
|
49
|
+
site) and must never render as live markup in the report.
|
|
50
|
+
|
|
51
|
+
## Consequences
|
|
52
|
+
|
|
53
|
+
- A new on-disk artifact, `report.html`, overwritten each run like the JSON.
|
|
54
|
+
`outputDir` is git-ignored; `maxPages` still bounds total size, and the
|
|
55
|
+
full node markup makes the HTML larger than the JSON per finding.
|
|
56
|
+
- The renderer duplicates presentation logic that a consumer of the JSON
|
|
57
|
+
would otherwise write. That is the point of shipping it, but it means the
|
|
58
|
+
clause/summary semantics now have two readers (JSON consumers and this
|
|
59
|
+
renderer) to keep in step.
|
|
60
|
+
- Adding a locale is a third column in `i18n.ts` plus a `Locale` union
|
|
61
|
+
member; the "same keys in every locale" test fails until it is complete.
|
|
62
|
+
- `reportLanguage` localises the report's own chrome — headings, labels,
|
|
63
|
+
the verdict, the legal note. axe-core's rule `help` text and node failure
|
|
64
|
+
summaries are English and are shown as axe emits them; translating
|
|
65
|
+
upstream rule copy is out of scope.
|
|
66
|
+
- PDF is still not produced. It needs a browser in the report path
|
|
67
|
+
(Playwright's `page.pdf()` over this HTML) and its own lifecycle and
|
|
68
|
+
testing story — a separate change. The print CSS in this one is written
|
|
69
|
+
with that in mind.
|
|
70
|
+
- The CLI still only prints "N rule(s) violated" — surfacing the breached
|
|
71
|
+
clauses in the terminal is a separate, small follow-up.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# 6. PDF report rendering
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-28
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
`bfsg.config.yaml` has always advertised PDF output alongside JSON and HTML.
|
|
12
|
+
JSON landed in ADR 0002 and the localised HTML in ADR 0005; the HTML is
|
|
13
|
+
already a self-contained document with inline CSS, no JavaScript, and a
|
|
14
|
+
`@media print` block written for this step. What is left is turning that
|
|
15
|
+
HTML into a PDF a non-engineer can file as the compliance record.
|
|
16
|
+
|
|
17
|
+
## Decision
|
|
18
|
+
|
|
19
|
+
**Print the HTML with headless Chromium** (`page.setContent(html)` then
|
|
20
|
+
`page.pdf()`), in a new `renderPdfReport(html, browser)` in the report
|
|
21
|
+
module. Playwright and Chromium are already the scanner's engine, so this
|
|
22
|
+
adds no dependency and no system binary. Chromium is the only engine
|
|
23
|
+
Playwright can print with; that constraint is already accepted everywhere
|
|
24
|
+
else in the tool. The HTML needs no server and no network — it carries its
|
|
25
|
+
own styles — so `setContent` is enough.
|
|
26
|
+
|
|
27
|
+
**One browser per run, owned by `run.ts`.** Until now `scan` launched and
|
|
28
|
+
closed its own Chromium. `run` now launches one Chromium (unless a browser
|
|
29
|
+
is injected, as tests do), passes it to `scan`, reuses it to print the PDF,
|
|
30
|
+
and closes it in a `finally`. `run` closes only a browser it launched
|
|
31
|
+
itself — the same contract `scan` keeps for an injected one. This replaces
|
|
32
|
+
one launch with one launch; it does not add a second.
|
|
33
|
+
|
|
34
|
+
**The PDF is written where the JSON and HTML are** — before the
|
|
35
|
+
partial-failure check, so a run that exits `3` for unreachable pages still
|
|
36
|
+
leaves all three artifacts. A failure inside `renderPdfReport` propagates
|
|
37
|
+
like any other write failure; the JSON and HTML are already on disk by
|
|
38
|
+
then, and there is no reason to treat a broken PDF render as recoverable
|
|
39
|
+
when a broken HTML write is not.
|
|
40
|
+
|
|
41
|
+
**A4, backgrounds on, ~15 mm margins.** Fixed, not configurable: the report
|
|
42
|
+
is a document to read and file, not a layout to tune.
|
|
43
|
+
|
|
44
|
+
## Consequences
|
|
45
|
+
|
|
46
|
+
- `run` now has a browser lifecycle. The early exits for bad config (`2`)
|
|
47
|
+
and empty discovery (`3`) still return before any launch. The scan and
|
|
48
|
+
report body moved into a `scanAndReport` helper so the `try/finally` that
|
|
49
|
+
owns the browser stays readable.
|
|
50
|
+
- `writeArtifact` now takes `string | Uint8Array`; `writeReport` and
|
|
51
|
+
`writeHtmlReport` are unchanged in behaviour.
|
|
52
|
+
- PDF size tracks HTML size, which `maxPages` bounds. A large scan produces
|
|
53
|
+
a large PDF; that is the same trade-off ADR 0002 made for the JSON.
|
|
54
|
+
- `renderPdfReport` needs a real Chromium to test, so its test launches one
|
|
55
|
+
like the scan tests do — it is not a pure unit like `renderHtmlReport`.
|
|
56
|
+
- Still not configurable: page size, margins, and whether the PDF is
|
|
57
|
+
written at all. If a user only wants JSON, that is a `--format` flag for
|
|
58
|
+
later, not part of adding the format the config already promises.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# 7. CLI argument surface
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-31
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
Until now the CLI understood one flag, `--config <path>`, and defaulted to
|
|
12
|
+
`bfsg.config.yaml`. Scanning a single URL meant writing a YAML file first,
|
|
13
|
+
`--help` and `--version` did not exist (`run(['--help'])` tried to load a
|
|
14
|
+
config and exited 2), and the report formats could not be narrowed — every
|
|
15
|
+
run wrote JSON, HTML, and PDF.
|
|
16
|
+
|
|
17
|
+
## Decision
|
|
18
|
+
|
|
19
|
+
**Parser: `node:util` `parseArgs`.** Built in, no dependency. `strict` mode
|
|
20
|
+
rejects unknown options; a `TypeError` from it is rewrapped as `ConfigError`
|
|
21
|
+
so bad arguments exit 2 like a bad config.
|
|
22
|
+
|
|
23
|
+
**Argument surface:**
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
bfsg-scanner [url] [options]
|
|
27
|
+
|
|
28
|
+
-c, --config <path> YAML config (default: bfsg.config.yaml)
|
|
29
|
+
--fail-on <level> critical | serious | moderate | minor
|
|
30
|
+
--report-language <l> de | en
|
|
31
|
+
--output-dir <dir>
|
|
32
|
+
--format <list> subset of json,html,pdf
|
|
33
|
+
-h, --help
|
|
34
|
+
-V, --version
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- A **positional URL** scans that page with defaults, no config file — the
|
|
38
|
+
common first-run case. It is mutually exclusive with `--config`; passing
|
|
39
|
+
both, or more than one URL, is an error.
|
|
40
|
+
- Only the four knobs a one-off run realistically varies get a flag.
|
|
41
|
+
`maxPages`, `wcagTags`, `excludePaths` are not mirrored: a run that needs
|
|
42
|
+
those needs a config file, and a flag for every field is a maintenance
|
|
43
|
+
cost with little payoff.
|
|
44
|
+
|
|
45
|
+
**`reportFormats` is a config field, not just a flag.** Every other knob
|
|
46
|
+
lives in the config; formats do too (`reportFormats`, default all three,
|
|
47
|
+
minimum one). `--format` overrides it. This is the `--format` flag ADR 0006
|
|
48
|
+
foresaw.
|
|
49
|
+
|
|
50
|
+
**One validation path.** CLI overrides are collected as raw values and
|
|
51
|
+
merged into the config object, which is then run through `configSchema`
|
|
52
|
+
(the new exported `parseConfig`). `--fail-on nope` fails with the same
|
|
53
|
+
machinery and message shape as `failOn: nope` in a file. `loadConfig` now
|
|
54
|
+
delegates its validation to `parseConfig` too.
|
|
55
|
+
|
|
56
|
+
**`toolInfo()` moved to `src/shared/`.** The package-manifest read that
|
|
57
|
+
stamps the report's `tool` field is the same thing `--version` needs; it
|
|
58
|
+
had one copy in `src/report/build.ts` and now has one shared copy.
|
|
59
|
+
|
|
60
|
+
## Consequences
|
|
61
|
+
|
|
62
|
+
- `reportFormats` selects which artifacts are written. With `pdf` absent the
|
|
63
|
+
run skips the browser `page.pdf()` call; with only `json` it skips HTML
|
|
64
|
+
rendering entirely. `buildReport` always runs — the verdict, exit code,
|
|
65
|
+
and breached-clause summary do not depend on any file being written.
|
|
66
|
+
- A minimum of one format: a run that writes nothing is almost always a
|
|
67
|
+
mistake, and `outputDir` can already point at a throwaway directory for
|
|
68
|
+
the rare "exit code only" case.
|
|
69
|
+
- `parseArgs` config errors ("Option '--config' argument missing",
|
|
70
|
+
"Unknown option '--nope'") surface verbatim. They are clear enough not to
|
|
71
|
+
reword.
|
|
72
|
+
- Not added: a positional URL list, `--quiet`/`--verbose`, config-file
|
|
73
|
+
discovery up the directory tree. Out of scope until asked for.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# 8. A JSON Schema for report.json
|
|
2
|
+
|
|
3
|
+
Date: 2026-08-31
|
|
4
|
+
|
|
5
|
+
## Status
|
|
6
|
+
|
|
7
|
+
Accepted
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
`report.json` has carried `schemaVersion: 1` since ADR 0002, and ADRs 0003,
|
|
12
|
+
0004, and 0005 each noted that the version "starts mattering at the first
|
|
13
|
+
release" and deferred a formal schema. Nothing has ever described the file's
|
|
14
|
+
shape to a consumer, and nothing stops an incompatible change to
|
|
15
|
+
`src/report/types.ts` from shipping without anyone noticing the on-disk
|
|
16
|
+
format moved.
|
|
17
|
+
|
|
18
|
+
## Decision
|
|
19
|
+
|
|
20
|
+
Add **`schema/report.v1.json`**, a JSON Schema (draft 2020-12) for the
|
|
21
|
+
report envelope, and ship it in the package (`files`).
|
|
22
|
+
|
|
23
|
+
**Hand-written, not generated.** The report types are plain TypeScript
|
|
24
|
+
interfaces, not zod schemas, so there is nothing to derive a schema from
|
|
25
|
+
without adding a type-to-schema codegen tool and a build step. The schema
|
|
26
|
+
is written by hand against `types.ts`.
|
|
27
|
+
|
|
28
|
+
**A validation test is the drift guard.** `src/report/report-schema.test.ts`
|
|
29
|
+
builds a report with `buildReport` from a synthetic scan result that
|
|
30
|
+
exercises every branch — rated and unrated violations, clauses present and
|
|
31
|
+
absent, an incomplete result, a clean page, a failed page — and asserts it
|
|
32
|
+
validates. Every object in the schema sets `additionalProperties: false`
|
|
33
|
+
with a complete `required` list, so **adding or removing a field in
|
|
34
|
+
`types.ts` breaks this test** until the schema is updated to match. The
|
|
35
|
+
test also checks that `SCHEMA_VERSION` equals the schema's `const`.
|
|
36
|
+
|
|
37
|
+
**`schemaVersion: 1` is now frozen.** A backwards-incompatible change to the
|
|
38
|
+
envelope — a removed field, a narrowed type, a restructured `pages` entry —
|
|
39
|
+
requires a new `schema/report.v2.json` and bumping `SCHEMA_VERSION`.
|
|
40
|
+
Additive, optional fields can stay at 1.
|
|
41
|
+
|
|
42
|
+
**`ajv` as a dev dependency** (test only), with a one-line `date-time`
|
|
43
|
+
format check registered by hand rather than pulling in `ajv-formats`.
|
|
44
|
+
|
|
45
|
+
## Consequences
|
|
46
|
+
|
|
47
|
+
- The schema describes the report *envelope*. It does not re-validate
|
|
48
|
+
axe-core's internals: `node.target` is "array of strings or string
|
|
49
|
+
arrays" and `tags` / clause strings are unconstrained beyond being
|
|
50
|
+
strings. Their correctness is axe's and the clause mapper's job, tested
|
|
51
|
+
elsewhere.
|
|
52
|
+
- Two sources of truth for the report shape now exist — `types.ts` and this
|
|
53
|
+
file — kept in step by the test rather than by generation. That is the
|
|
54
|
+
cost of not adding codegen; the test makes the cost visible rather than
|
|
55
|
+
silent.
|
|
56
|
+
- Consumers can validate a stored `report.json` against the shipped schema
|
|
57
|
+
with any JSON Schema validator.
|
|
58
|
+
- `format: date-time` on `generatedAt` is advisory in the file (many
|
|
59
|
+
validators do not enforce formats by default); the test enforces it.
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bfsg-scanner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WCAG 2.1 AA / BFSG compliance scanner built on Playwright and axe-core",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"accessibility",
|
|
7
|
+
"a11y",
|
|
8
|
+
"wcag",
|
|
9
|
+
"wcag2",
|
|
10
|
+
"en-301-549",
|
|
11
|
+
"bfsg",
|
|
12
|
+
"axe-core",
|
|
13
|
+
"playwright",
|
|
14
|
+
"accessibility-testing",
|
|
15
|
+
"compliance",
|
|
16
|
+
"cli"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/MihaelaAghirculesei/bfsg-scanner#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/MihaelaAghirculesei/bfsg-scanner/issues"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/MihaelaAghirculesei/bfsg-scanner.git"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"author": "Mihaela Aghirculesei",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=24.0.0"
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"bfsg-scanner": "dist/cli/index.js"
|
|
34
|
+
},
|
|
35
|
+
"main": "dist/index.js",
|
|
36
|
+
"types": "dist/index.d.ts",
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"schema",
|
|
40
|
+
"docs",
|
|
41
|
+
"CHANGELOG.md"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"prepare": "git config core.hooksPath .githooks || exit 0",
|
|
45
|
+
"clean": "rimraf dist",
|
|
46
|
+
"build": "npm run clean && tsc -p tsconfig.build.json",
|
|
47
|
+
"cli": "tsx src/cli/index.ts",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"lint": "biome check .",
|
|
50
|
+
"lint:fix": "biome check --write .",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:watch": "vitest",
|
|
53
|
+
"verify": "npm run typecheck && npm run lint && npm run test",
|
|
54
|
+
"prepublishOnly": "npm run verify && npm run build"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@axe-core/playwright": "4.13.0",
|
|
58
|
+
"axe-core": "4.13.0",
|
|
59
|
+
"fast-xml-parser": "5.11.0",
|
|
60
|
+
"playwright": "1.62.1",
|
|
61
|
+
"yaml": "2.9.0",
|
|
62
|
+
"zod": "4.4.3"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@biomejs/biome": "2.5.10",
|
|
66
|
+
"@types/node": "24.9.2",
|
|
67
|
+
"ajv": "8.20.0",
|
|
68
|
+
"rimraf": "6.0.1",
|
|
69
|
+
"tsx": "4.23.12",
|
|
70
|
+
"typescript": "5.9.3",
|
|
71
|
+
"vitest": "4.1.11"
|
|
72
|
+
}
|
|
73
|
+
}
|