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.
Files changed (104) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +147 -0
  4. package/dist/cli/index.d.ts +2 -0
  5. package/dist/cli/index.js +4 -0
  6. package/dist/cli/index.js.map +1 -0
  7. package/dist/cli/run.d.ts +29 -0
  8. package/dist/cli/run.js +202 -0
  9. package/dist/cli/run.js.map +1 -0
  10. package/dist/config/index.d.ts +2 -0
  11. package/dist/config/index.js +3 -0
  12. package/dist/config/index.js.map +1 -0
  13. package/dist/config/load.d.ts +12 -0
  14. package/dist/config/load.js +47 -0
  15. package/dist/config/load.js.map +1 -0
  16. package/dist/config/schema.d.ts +51 -0
  17. package/dist/config/schema.js +16 -0
  18. package/dist/config/schema.js.map +1 -0
  19. package/dist/discovery/crawl.d.ts +21 -0
  20. package/dist/discovery/crawl.js +79 -0
  21. package/dist/discovery/crawl.js.map +1 -0
  22. package/dist/discovery/discover-site.d.ts +26 -0
  23. package/dist/discovery/discover-site.js +50 -0
  24. package/dist/discovery/discover-site.js.map +1 -0
  25. package/dist/discovery/discover.d.ts +25 -0
  26. package/dist/discovery/discover.js +81 -0
  27. package/dist/discovery/discover.js.map +1 -0
  28. package/dist/discovery/index.d.ts +11 -0
  29. package/dist/discovery/index.js +7 -0
  30. package/dist/discovery/index.js.map +1 -0
  31. package/dist/discovery/links.d.ts +11 -0
  32. package/dist/discovery/links.js +69 -0
  33. package/dist/discovery/links.js.map +1 -0
  34. package/dist/discovery/robots.d.ts +18 -0
  35. package/dist/discovery/robots.js +91 -0
  36. package/dist/discovery/robots.js.map +1 -0
  37. package/dist/discovery/sitemap.d.ts +18 -0
  38. package/dist/discovery/sitemap.js +36 -0
  39. package/dist/discovery/sitemap.js.map +1 -0
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.js +2 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/report/build.d.ts +14 -0
  44. package/dist/report/build.js +100 -0
  45. package/dist/report/build.js.map +1 -0
  46. package/dist/report/clauses.d.ts +32 -0
  47. package/dist/report/clauses.js +57 -0
  48. package/dist/report/clauses.js.map +1 -0
  49. package/dist/report/i18n.d.ts +54 -0
  50. package/dist/report/i18n.js +95 -0
  51. package/dist/report/i18n.js.map +1 -0
  52. package/dist/report/index.d.ts +12 -0
  53. package/dist/report/index.js +8 -0
  54. package/dist/report/index.js.map +1 -0
  55. package/dist/report/pdf.d.ts +12 -0
  56. package/dist/report/pdf.js +25 -0
  57. package/dist/report/pdf.js.map +1 -0
  58. package/dist/report/render.d.ts +8 -0
  59. package/dist/report/render.js +204 -0
  60. package/dist/report/render.js.map +1 -0
  61. package/dist/report/types.d.ts +78 -0
  62. package/dist/report/types.js +2 -0
  63. package/dist/report/types.js.map +1 -0
  64. package/dist/report/verdict.d.ts +20 -0
  65. package/dist/report/verdict.js +28 -0
  66. package/dist/report/verdict.js.map +1 -0
  67. package/dist/report/write.d.ts +21 -0
  68. package/dist/report/write.js +35 -0
  69. package/dist/report/write.js.map +1 -0
  70. package/dist/scan/index.d.ts +2 -0
  71. package/dist/scan/index.js +2 -0
  72. package/dist/scan/index.js.map +1 -0
  73. package/dist/scan/scan.d.ts +2 -0
  74. package/dist/scan/scan.js +82 -0
  75. package/dist/scan/scan.js.map +1 -0
  76. package/dist/scan/types.d.ts +56 -0
  77. package/dist/scan/types.js +2 -0
  78. package/dist/scan/types.js.map +1 -0
  79. package/dist/shared/index.d.ts +4 -0
  80. package/dist/shared/index.js +5 -0
  81. package/dist/shared/index.js.map +1 -0
  82. package/dist/shared/package-info.d.ts +10 -0
  83. package/dist/shared/package-info.js +11 -0
  84. package/dist/shared/package-info.js.map +1 -0
  85. package/dist/shared/pool.d.ts +7 -0
  86. package/dist/shared/pool.js +25 -0
  87. package/dist/shared/pool.js.map +1 -0
  88. package/dist/shared/rate-limiter.d.ts +11 -0
  89. package/dist/shared/rate-limiter.js +26 -0
  90. package/dist/shared/rate-limiter.js.map +1 -0
  91. package/dist/shared/user-agent.d.ts +11 -0
  92. package/dist/shared/user-agent.js +12 -0
  93. package/dist/shared/user-agent.js.map +1 -0
  94. package/docs/SCANNING-ETHICS.md +52 -0
  95. package/docs/adr/0001-page-discovery-strategy.md +74 -0
  96. package/docs/adr/0002-json-report-output.md +54 -0
  97. package/docs/adr/0003-failon-threshold-and-exit-codes.md +72 -0
  98. package/docs/adr/0004-clause-mapping-from-axe-tags.md +81 -0
  99. package/docs/adr/0005-html-report-rendering.md +71 -0
  100. package/docs/adr/0006-pdf-report-rendering.md +58 -0
  101. package/docs/adr/0007-cli-argument-surface.md +73 -0
  102. package/docs/adr/0008-report-json-schema.md +59 -0
  103. package/package.json +73 -0
  104. 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
+ }