@bounded-systems/conformance-kit 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bounded Systems
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,143 @@
1
+ # @bounded-systems/conformance-kit
2
+
3
+ A standalone, **site-agnostic web-conformance toolkit**: build-integrity tooling,
4
+ fail-closed conformance gates, and provenance generators — extracted from
5
+ `bdelanghe/site` and `bounded-systems/site` and **generalized** so a site vendors
6
+ **one kit** instead of duplicating scripts.
7
+
8
+ Every site value (paths, thresholds, site URL, account/repo id, issuer/DID, SHACL
9
+ shapes, the markdown renderer, the prose corpus, the build itself) is an **INPUT**,
10
+ injected by the consumer via CLI args, env vars, or a passed config. Nothing here
11
+ hardcodes `robertdelanghe.dev`, `bounded.tools`, an account, or an email.
12
+
13
+ ```
14
+ integrity/ verify-site · verify (sigstore) · gen-sitemanifest · gen-provenance · structure-audit · http-probe
15
+ gates/ sbom (gen + completeness) · shacl-runner · seo-gate · axe-gate (axe-core a11y) · vuln-gate (npm audit) · html-validator-gate (vnu) · baseline-gate (web-features) · readability-gate · commonmark-runner · semantic (lone)
16
+ gates/conformance/ conformance-report — lone's conformance() projection (Node port of jsr:@bounded-systems/lone@0.4) + a generic HTML renderer
17
+ generators/ gen-cid (IPFS UnixFS) · gen-identity (did:web + VC) · gen-snapshots (reader/markdown) · openapi (static-API helper core)
18
+ emitters/ reprDigest (RFC 9530) · securityTxt (RFC 9116) · webManifest · markdown-sibling headers
19
+ lib/ schema-validate (zero-dep JSON Schema) · config (env/arg helpers)
20
+ fixtures/ test/ isolated verification of the generic logic
21
+ ```
22
+
23
+ Design rules: zero-dep where the source was zero-dep; pure/offline gates read only
24
+ the built output; deterministic generators are a function of their inputs (no wall
25
+ clock); fail-closed (`exit 1`) on any violation.
26
+
27
+ ## Install / vendor
28
+
29
+ Three consumption models:
30
+
31
+ 1. **Vendor (recommended, matches the existing `vendor/integrity/` pattern).** Copy
32
+ the kit at a pinned commit into `vendor/conformance-kit/`, write a hash-pin
33
+ manifest (see [`vendor.example.json`](./vendor.example.json) — mirrors
34
+ `bdelanghe/site` `vendor/integrity/provenance.json`: `source`, `commit`,
35
+ `fetched`, `files{path: sha256}`), and verify against it before every use. The
36
+ site then `import`s / invokes the vendored copies. The kit's own
37
+ [`provenance.json`](./provenance.json) records which source repo + commit each
38
+ tool was generalized from.
39
+ 2. **npm dep.** `npm i @bounded-systems/conformance-kit` and use the `ck-*` bins
40
+ (see `package.json`) or `import` the library modules.
41
+ 3. **Nix flake (reproducible, runtime-bundled).** `nix run
42
+ github:bounded-systems/conformance-kit#ck-axe-gate -- dist`, or add the flake to
43
+ a `home-manager` / `nix profile`. Each `ck-*` bin is a hermetic, pinned closure;
44
+ the gates that shell out get their runtime bundled in — `ck-html-validator-gate`
45
+ carries a JRE for vnu, `ck-vuln-gate` carries npm — so no JRE/Node on `$PATH` is
46
+ needed. (`ck-axe-gate` still needs a browser the consumer supplies via
47
+ `$AXE_RUNNER`: `tezcatl` or Playwright.)
48
+
49
+ Runtime deps are declared in `package.json` (only the gates that need them pull
50
+ them: `linkedom`/`@mozilla/readability` for structure-audit; `jsonld`/`n3`/
51
+ `@zazuko/env-node`/`rdf-validate-shacl` for the SHACL runner; `sigstore` for the
52
+ in-process verifier). The Deno semantic runner pins its imports in
53
+ `gates/semantic/deno.json`.
54
+
55
+ ## Tools — what each does + **how a site consumes it** (the input it must supply)
56
+
57
+ ### integrity/
58
+
59
+ | Tool | Invoke | Consumer supplies |
60
+ |---|---|---|
61
+ | `gen-sitemanifest.mjs` | `DIST=dist node …/gen-sitemanifest.mjs` | `$DIST` (build dir). Optional `$MANIFEST_EXCLUDE` (extra platform control files). Emits `$DIST/site.sha256`. |
62
+ | `gen-provenance.mjs` | run at deploy after signing | GitHub Actions env (`GITHUB_*`), `$OCI_REF`/`$OCI_DIGEST`, optional `$PROVENANCE_DOC_URL`, `$DIST`. The emitted `builder.repository` becomes the identity the verifiers enforce. |
63
+ | `verify-site.mjs` | `node …/verify-site.mjs <https://site \| ./dist>` | A deployed site (or local dir) carrying `provenance.json` + `site.sha256` + its `.sigstore.json` bundle. Identity is read from `provenance.builder.repository` — nothing hardcoded. Shells to `cosign` if present, else SKIPs with a recipe. |
64
+ | `verify/verify.mjs` | `node …/verify/verify.mjs <url\|dir>` | Same inputs; verifies the Sigstore **bundle** in-process (offline) via `sigstore-js`. |
65
+ | `structure-audit/audit.mjs` | `node …/audit.mjs <distDir> [--check]` | `<distDir>`. Optional `$STRUCTURE_ARTICLE_PREFIX` (default `blog/`), `$STRUCTURE_ERROR_PAGE` (default `404.html`), `$STRUCTURE_AUDIT_SIDECARS` (deploy-time live paths, e.g. `/resume.pdf`), `$STRUCTURE_BASELINE` (where the committed `structure.json` lives — keep it in the **consumer**, not the vendored kit). |
66
+ | `http-probe.mjs` | `node …/http-probe.mjs <https://site> [config.json]` | A live URL **and** a probe config: `$PROBE_CONFIG`/2nd arg JSON `{htmlRoutes,typed,missing}`, or `$PROBE_HTML_ROUTES`+`$PROBE_MISSING`. Routes are NOT hardcoded. |
67
+
68
+ ### gates/
69
+
70
+ | Tool | Invoke | Consumer supplies |
71
+ |---|---|---|
72
+ | `sbom/gen-sbom.mjs` | `ROOT=. DIST=dist node …/gen-sbom.mjs` | `$ROOT` (lockfiles live here), `$SBOM_LOCKFILES` (comma list, default `package-lock.json`), `$SBOM_NAME`, `$SBOM_NAMESPACE_BASE`, `$SBOM_CREATORS`. Reads `flake.lock` if present. Emits `$DIST/sbom.spdx.json`. |
73
+ | `sbom/check-sbom.mjs` | `ROOT=. DIST=dist node …/check-sbom.mjs` | Same `$ROOT`/`$DIST`. Fails closed unless pinned-set ⊆ SBOM ⊆ pinned-set and (optionally) the in-toto attestation reconciles. |
74
+ | `shacl-runner.mjs` | `node …/shacl-runner.mjs <shapes.ttl> <htmlDir>` | **The SHACL shapes file stays in the site** (its structured-data contract) + the built-HTML dir. Optional `$SHACL_CONTEXT` (custom offline JSON-LD context; default schema.org). Fails unless every JSON-LD block `conforms: true`. |
75
+ | `seo-gate.mjs` | `node …/seo-gate.mjs [distDir]` | `$DIST`. Optional `$SEO_ERROR_PAGE`, `$SEO_DEPLOY_SIDECARS`. Enforces canonical/title/description uniqueness + self-consistency, robots.txt (RFC 9309), sitemap, internal links. |
76
+ | `axe-gate.mjs` | `node …/axe-gate.mjs [distDir]` | `$DIST`. Optional `$AXE_PAGES` (comma list, default: every `*.html` in dist), `$AXE_TAGS` (default `wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa`), `$AXE_IMPACT_THRESHOLD` (`minor`/`moderate`/`serious`/`critical`, default `serious`), `$AXE_RUNNER` (`playwright` (CI, needs `playwright` + `@axe-core/playwright` + `npx playwright install chromium`) \| `tezcatl` (macOS WebKit, local)), `$AXE_REPORT` (write the JSON report). Serves dist over an ephemeral origin (so assets resolve), runs **axe-core** per page, and **fails closed** on any violation at/above the threshold. The emitted report's `axe: { serious, critical }` envelope is exactly what `conformance-report`'s `a11y.axe-serious-critical` criterion consumes — a clean run is what lets a site honestly assert it. |
77
+ | `vuln-gate.mjs` | `node …/vuln-gate.mjs [projectDir]` | `$VULN_ROOT` (lockfile lives here, default `.`). Optional `$VULN_OMIT_DEV` (`true`→production deps only, default `true`), `$VULN_THRESHOLD` (highest tolerated known critical/high, default `0`), `$VULN_REPORT` (write the JSON report). Runs **`npm audit`** and **fails closed** when the known critical/high count exceeds the threshold. The report's `vulns: { knownCriticalOrHighVulns }` envelope is what `conformance-report`'s `security.no-critical-vulns` criterion consumes. |
78
+ | `html-validator-gate.mjs` | `node …/html-validator-gate.mjs [distDir]` | `$HTML_DIST`. Optional `$HTML_PAGES` (comma list, default: every `*.html`), `$HTML_THRESHOLD` (default `0`), `$HTML_REPORT`. Runs **vnu** (the Nu Html Checker, a self-contained Java jar — needs a JRE) `--errors-only` over the built pages and **fails closed** above the threshold. The report's `htmlValidator: { errors }` envelope is what `conformance-report`'s `html.validator-clean` criterion consumes. |
79
+ | `baseline-gate.mjs` | `node …/baseline-gate.mjs [cssGlob]` | `$BASELINE_CSS` (default `dist/**/*.css`). Optional `$BASELINE_TARGET` (`widely`/`newly`, default `widely`), `$BASELINE_REPORT`. Maps the shipped CSS to **web-features Baseline** data (via `stylelint-plugin-use-baseline` — headless, no browser) and **fails closed** when the site-wide status is below target. A feature behind an `@supports` query is a tested fallback and doesn't count against it. The report's `baseline: { status, fallbackTested }` envelope is what `conformance-report`'s `compatibility.baseline` criterion consumes. |
80
+ | `readability-gate.mjs` | `node …/readability-gate.mjs <corpus.json> [--strict]` | **The corpus is an input** the site assembles from its copy: a JSON array of `{id,text}` or an `{id:text}` map. Optional `$READABILITY_THRESHOLDS`, `$READABILITY_MIN_WORDS`, `$READABILITY_KNOWN_ACRONYMS`. WARN-only unless `--strict`. |
81
+ | `commonmark-runner.mjs` | `node …/commonmark-runner.mjs <renderer.mjs> [fixtures.json]` | **The site's markdown renderer module** (export `renderMarkdown`, or set `$COMMONMARK_RENDER_EXPORT`). Default fixtures pin a safe CommonMark subset + 4 hostile-HTML escapes; a site with a different renderer supplies its own `fixtures.json`. |
82
+ | `semantic/gate.ts` | `deno run --allow-read --allow-net …/gate.ts` | Built HTML in `$SEMANTIC_DIR` (default `dist/blog`); `$SEMANTIC_SELECTOR` (subject node, default `article`). Imports `jsr:@bounded-systems/lone`; any error-severity finding fails CI. |
83
+ | `conformance-report.mjs` | `import { buildConformanceReport, renderConformanceReport } from "…/gates/conformance-report.mjs"` | **The site's evidence** — `loneFindings` (the semantic gate's DOM findings, or `null` when no DOM was blessed → those criteria report `not-assessed`) + an external-evidence envelope whose fields it gathers from its own gates (`jsonLdShacl`, `sbom`, `contentDigests`, `slsaProvenance`, …). `renderConformanceReport(report, { evidenceHref })` → a class-based HTML fragment; the consumer wraps it in its template and supplies per-criterion evidence URLs. Zero-dep; the conformance MODEL is a Node port of `jsr:@bounded-systems/lone@0.4`'s `conformance()` in `gates/conformance/`. |
84
+
85
+ The conformance projection makes overclaim impossible by construction: the strong
86
+ compact claim (`COMPACT_CLAIM`) is emitted **only** when every tier-1 `required`
87
+ criterion has passing evidence; unsupplied criteria (manual WCAG audit, OWASP ASVS,
88
+ field Core Web Vitals, Baseline) are `not-assessed`, never `met` — so automation can
89
+ never print "WCAG 2.2 AA" or "ASVS conformant" on its own. tier-2/tier-3/cognitive
90
+ criteria are reported + summarised per area but never widen the headline claim.
91
+
92
+ ### generators/
93
+
94
+ | Tool | Invoke | Consumer supplies |
95
+ |---|---|---|
96
+ | `gen-cid.mjs` | `DIST=dist node …/gen-cid.mjs` | `$DIST`. Walks the `site.sha256` file set (or `dist`), computes the IPFS UnixFS dir CIDv1 with no daemon, records it into `$DIST/provenance.json`. |
97
+ | `gen-identity.mjs` | `IDENTITY_DOMAIN=… IDENTITY_REPO=owner/repo node …/gen-identity.mjs` | `$IDENTITY_DOMAIN`, `$IDENTITY_REPO` (cert-identity regexp), `$IDENTITY_SUBJECT` (the credentialSubject JSON, default `$DIST/resume.json`), optional `$IDENTITY_SUBJECT_SCHEMA`, `$IDENTITY_VC_NAME/DESCRIPTION`, `$IDENTITY_VALID_FROM_PATH`. Emits `did.json` + a W3C VC 2.0. |
98
+ | `gen-snapshots.mjs` | `node …/gen-snapshots.mjs [distDir]` | `$SNAPSHOT_DIST` (default `dist`). Optional `$SNAPSHOT_PAGES`, `$SNAPSHOT_BASE_URL` (recorded as `source` in the front-matter), `$SNAPSHOT_SUFFIX` (default `.reader`). For every built page, runs **@mozilla/readability** (the Firefox/Safari Reader engine, via `linkedom` — headless, no browser) and writes a clean reader **`<page>.reader.html`** + an analysis-friendly **`<page>.reader.md`** (YAML front-matter + Markdown via `turndown`). The Markdown is the durable, diffable twin of the page — far easier to run NLP/LLM analysis over than scraping live HTML — and doubles as the AI-readable Markdown sibling. (The printed/PDF view needs a print-CSS renderer and is a separate generator.) |
99
+ | `openapi.mjs` | `import { sortKeys, writeApiFile, embedSchema, jsonResponse, validateOpenapi }` | The **generic core** of a static-API generator. The per-endpoint projection of a site's contracts (profile/posts/corpus/VC, etc.) stays in the site's build; this module provides deterministic JSON output, schema embedding, and OpenAPI 3.1/3.2 well-formedness validation. Pair with `lib/schema-validate.mjs` to self-check emitted docs. |
100
+
101
+ ### emitters/
102
+
103
+ `import { reprDigest, securityTxt, securityTxtExpires, webManifest, markdownSiblingHeaders } from "…/emitters/index.mjs"` — pure helpers a site's own `build.mjs` calls to emit standards-compliant artifacts (RFC 9530 `Repr-Digest`, RFC 9116 `security.txt`, the W3C web app manifest, the `_headers` Content-Type rules for `.md` siblings). All values injected; the page **content** stays in the site.
104
+
105
+ ## `@bounded-systems/verify` (vendored here; published elsewhere)
106
+
107
+ The in-process Sigstore verifier (`integrity/verify/verify.mjs`) is **vendored** in
108
+ this kit so sites can pull it into a hermetic build. It is no longer **published**
109
+ from here: the canonical home of the [`@bounded-systems/verify`](https://jsr.io/@bounded-systems/verify)
110
+ JSR package is now its own repo,
111
+ [`bounded-systems/verify`](https://github.com/bounded-systems/verify). That repo owns
112
+ the package manifest (`deno.json`) and the keyless-OIDC release workflow; cut releases
113
+ there. The copy here is kept byte-for-byte in sync with the published source.
114
+
115
+ Consumers run it straight from JSR:
116
+
117
+ ```sh
118
+ deno run -A jsr:@bounded-systems/verify https://your-site
119
+ ```
120
+
121
+ ## Test
122
+
123
+ ```
124
+ npm install && npm test # 13 cases against fixtures/, in isolation
125
+ ```
126
+
127
+ The suite verifies the generic logic end-to-end: gen-sbom against a sample lockfile;
128
+ shacl-runner against sample shapes+HTML → `conforms: true`; structure-audit / seo /
129
+ readability / commonmark against sample inputs; gen-sitemanifest + gen-cid + verify-site
130
+ round-trip on a sample build; gen-identity; the emitter/openapi/schema helpers; the
131
+ conformance projection; and the **axe-gate** (its classification/threshold/report logic
132
+ deterministically, plus a real end-to-end pass on the known-bad + known-good
133
+ `fixtures/axe/` snippets when a browser engine — tezcatl or Playwright/Chromium — is on
134
+ PATH; skipped, like the cosign step, when none is). (The Deno semantic runner is
135
+ exercised by the consuming site, as it needs Deno + JSR.)
136
+
137
+ ## Provenance / determinism
138
+
139
+ The gates are pure functions of the built output; the generators are deterministic
140
+ functions of their inputs (the SBOM creation date is derived from `flake.lock`, never
141
+ a wall clock; the CID re-derives from the served bytes with any IPFS implementation).
142
+ Site-specific artifacts — SHACL shapes, the prose corpus, the markdown renderer,
143
+ thresholds, copy, and `build.mjs` itself — are inputs, never part of the kit.
@@ -0,0 +1,68 @@
1
+ // emitters — pure helpers for the standards-compliant build artifacts a site's
2
+ // own build.mjs emits. Each is a PURE FUNCTION of injected config; nothing here
3
+ // reads a site's content model, so the actual copy/data stays in the consumer.
4
+ // Extracted from the emitters embedded in bdelanghe/site/build.mjs.
5
+ import { createHash } from "node:crypto";
6
+
7
+ // RFC 9530 representation digest: sha-256 of a doc's exact served bytes, as a
8
+ // structured-field byte sequence — `sha-256=:<base64>:`. Compute over the bytes the
9
+ // build itself writes (self-contained), per canonical document, then add as a
10
+ // `Repr-Digest:` response header for that route.
11
+ export const reprDigest = (buf) =>
12
+ "sha-256=:" + createHash("sha256").update(Buffer.isBuffer(buf) ? buf : Buffer.from(buf)).digest("base64") + ":";
13
+
14
+ // RFC 9116 /.well-known/security.txt — a machine-readable security-contact channel.
15
+ // securityTxt({ contact, canonical, expires, preferredLanguages })
16
+ // `contact` is one or more mailto:/https: values; `expires` is an ISO timestamp (a
17
+ // year out from the build date is the convention, so a weekly rebuild rolls it
18
+ // forward and it never goes stale).
19
+ export function securityTxt({ contact, canonical, expires, preferredLanguages = ["en"] } = {}) {
20
+ if (!contact) throw new Error("securityTxt: `contact` is required (mailto: or https: URL)");
21
+ if (!expires) throw new Error("securityTxt: `expires` (ISO timestamp) is required");
22
+ const contacts = Array.isArray(contact) ? contact : [contact];
23
+ const lines = [
24
+ ...contacts.map((c) => `Contact: ${c}`),
25
+ `Expires: ${expires}`,
26
+ ...(canonical ? [`Canonical: ${canonical}`] : []),
27
+ ...(preferredLanguages?.length ? [`Preferred-Languages: ${preferredLanguages.join(", ")}`] : []),
28
+ ];
29
+ return lines.join("\n") + "\n";
30
+ }
31
+
32
+ // `expires` one year out from a reference date (ISO string), the security.txt convention.
33
+ export const securityTxtExpires = (fromISO = new Date().toISOString()) => {
34
+ const d = new Date(fromISO);
35
+ d.setUTCFullYear(d.getUTCFullYear() + 1);
36
+ return d.toISOString();
37
+ };
38
+
39
+ // W3C Web App Manifest (no service worker). All values injected by the consumer —
40
+ // typically from its design tokens so the <head> theme-color and the manifest can't
41
+ // drift. Returns the manifest object; the caller JSON.stringifies + writes it to
42
+ // /site.webmanifest and serves it as `application/manifest+json`.
43
+ export function webManifest({
44
+ name, shortName, description,
45
+ themeColor, backgroundColor,
46
+ display = "standalone", startUrl = "/", icons = [],
47
+ } = {}) {
48
+ if (!name) throw new Error("webManifest: `name` is required");
49
+ return {
50
+ name,
51
+ ...(shortName ? { short_name: shortName } : { short_name: name.split(" ")[0] }),
52
+ ...(description ? { description } : {}),
53
+ ...(themeColor ? { theme_color: themeColor } : {}),
54
+ ...(backgroundColor ? { background_color: backgroundColor } : {}),
55
+ display,
56
+ start_url: startUrl,
57
+ icons,
58
+ };
59
+ }
60
+
61
+ // The Cloudflare-_headers Content-Type rules a site needs once it serves Markdown
62
+ // siblings (/index.md, /resume.md, /blog/<slug>.md) + a web app manifest. Returned
63
+ // as a string a consumer concatenates into its own _headers file. The Markdown
64
+ // SIBLING CONTENT itself (rendering a page to text/markdown) is the site's job —
65
+ // only the serving rule is generic.
66
+ export const markdownSiblingHeaders = () =>
67
+ `/*.md\n Content-Type: text/markdown; charset=utf-8\n` +
68
+ `/site.webmanifest\n Content-Type: application/manifest+json; charset=utf-8\n`;
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ // axe accessibility gate — turns "we ran axe once" into a CONTINUOUSLY-ENFORCED
3
+ // member of the conformance contract. It loads each BUILT page in a real browser,
4
+ // runs axe-core with the WCAG 2.x A/AA ruleset, and FAILS CLOSED (exit 1) on any
5
+ // violation at or above a configurable impact threshold (default: serious). The
6
+ // machine-readable result it emits is exactly the shape lone's conformance() model
7
+ // consumes for `a11y.axe-serious-critical` (`{ serious, critical }`), so a clean run
8
+ // is what lets a site honestly assert that criterion — and a regression turns CI red.
9
+ //
10
+ // node gates/axe-gate.mjs [distDir] # build gate (exit 1 on any blocking violation)
11
+ //
12
+ // Pure data in → typed report out. The browser is the ONLY impurity: axe needs real
13
+ // layout/computed-style (e.g. colour-contrast, target-size), so a DOM shim is not
14
+ // enough. Two interchangeable runners drive a real engine:
15
+ // - playwright (default) — `@axe-core/playwright` + Playwright's bundled Chromium.
16
+ // The CI runner: hermetic, headless, cross-platform.
17
+ // - tezcatl — macOS-native headless WebKit. Injects axe.min.js into the
18
+ // served page and reads the result back. The LOCAL runner (no Chromium download).
19
+ // Both serve dist/ over an ephemeral localhost HTTP origin first, so absolute asset
20
+ // paths (`/assets/…css`, fonts) resolve — running file:// would strip the styles and
21
+ // fabricate layout-dependent violations.
22
+ //
23
+ // Everything is config-driven; NOTHING about any one site is hard-coded:
24
+ // argv[2] / $DIST built output dir (default: "dist")
25
+ // $AXE_PAGES comma list of page paths under dist to scan
26
+ // (default: every *.html discovered in dist)
27
+ // $AXE_TAGS comma list of axe ruleset tags
28
+ // (default: wcag2a,wcag2aa,wcag21a,wcag21aa,wcag22aa)
29
+ // $AXE_IMPACT_THRESHOLD lowest impact that BLOCKS: minor|moderate|serious|critical
30
+ // (default: serious)
31
+ // $AXE_RUNNER playwright | tezcatl (default: playwright)
32
+ // $AXE_REPORT path to write the JSON report (default: none → stdout only)
33
+ // $AXE_TEZCATL_WAIT ms to let axe settle, tezcatl runner (default: 3000)
34
+ //
35
+ // The pure evaluation/report functions are exported for unit testing without a browser.
36
+ import { readFile, readdir, access, mkdtemp, writeFile } from "node:fs/promises";
37
+ import { createServer } from "node:http";
38
+ import { join, relative, resolve, extname } from "node:path";
39
+ import { tmpdir } from "node:os";
40
+ import { createRequire } from "node:module";
41
+ import { spawn } from "node:child_process";
42
+
43
+ // ── Pure core (browser-free; unit-testable) ──────────────────────────────────
44
+
45
+ /** Impact levels, weakest → strongest. A violation BLOCKS when its impact ranks at
46
+ * or above the configured threshold. axe may report `impact: null`; such findings
47
+ * rank below `minor` and so never block (but are still counted/reported). */
48
+ export const IMPACT_ORDER = ["minor", "moderate", "serious", "critical"];
49
+ export const DEFAULT_TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
50
+ export const DEFAULT_THRESHOLD = "serious";
51
+
52
+ export const impactRank = (impact) => IMPACT_ORDER.indexOf(impact); // -1 when null/unknown
53
+ export const blocksAt = (impact, threshold) => {
54
+ const t = impactRank(threshold);
55
+ return t >= 0 && impactRank(impact) >= t;
56
+ };
57
+
58
+ /** Normalise one axe violation to the compact, stable shape we report/persist. */
59
+ export function normalizeViolation(v) {
60
+ const nodes = Array.isArray(v.nodes) ? v.nodes : [];
61
+ const targets = nodes
62
+ .map((n) => (Array.isArray(n.target) ? n.target.join(" ") : String(n.target ?? "")))
63
+ .filter(Boolean);
64
+ return {
65
+ id: v.id,
66
+ impact: v.impact ?? null,
67
+ help: v.help ?? "",
68
+ helpUrl: v.helpUrl ?? "",
69
+ nodes: nodes.length,
70
+ targets: targets.slice(0, 8), // cap; full detail lives in axe's own helpUrl
71
+ };
72
+ }
73
+
74
+ /** Empty {critical,serious,moderate,minor,unknown} counter. */
75
+ const emptyCounts = () => ({ critical: 0, serious: 0, moderate: 0, minor: 0, unknown: 0 });
76
+
77
+ /** Evaluate one page's raw axe violations against the threshold. */
78
+ export function evaluatePage(page, rawViolations, threshold = DEFAULT_THRESHOLD) {
79
+ const violations = (rawViolations ?? []).map(normalizeViolation);
80
+ const counts = emptyCounts();
81
+ let blocking = 0;
82
+ for (const v of violations) {
83
+ counts[v.impact && v.impact in counts ? v.impact : "unknown"]++;
84
+ if (blocksAt(v.impact, threshold)) blocking++;
85
+ }
86
+ // Group by impact for the machine-readable report (serious/critical first).
87
+ const byImpact = {};
88
+ for (const lvl of [...IMPACT_ORDER].reverse()) {
89
+ const inLvl = violations.filter((v) => v.impact === lvl);
90
+ if (inLvl.length) byImpact[lvl] = inLvl;
91
+ }
92
+ const unknown = violations.filter((v) => impactRank(v.impact) < 0);
93
+ if (unknown.length) byImpact.unknown = unknown;
94
+ return { page, counts, blocking, violations: byImpact };
95
+ }
96
+
97
+ /** Fold per-page evaluations into the whole-run report consumed by conformance(). */
98
+ export function summarize(pageResults, { threshold = DEFAULT_THRESHOLD, tags = DEFAULT_TAGS, runner = "playwright" } = {}) {
99
+ const totals = emptyCounts();
100
+ let blocking = 0;
101
+ for (const p of pageResults) {
102
+ for (const k of Object.keys(totals)) totals[k] += p.counts[k];
103
+ blocking += p.blocking;
104
+ }
105
+ return {
106
+ tool: "axe-core",
107
+ runner,
108
+ standard: "WCAG 2.x A/AA (axe ruleset)",
109
+ tags,
110
+ impactThreshold: threshold,
111
+ generatedAt: new Date().toISOString(),
112
+ pages: pageResults,
113
+ totals,
114
+ // The exact envelope lone's `a11y.axe-serious-critical` evaluator reads.
115
+ axe: { serious: totals.serious, critical: totals.critical },
116
+ blocking, // count of violations at/above threshold across all pages
117
+ passed: blocking === 0,
118
+ };
119
+ }
120
+
121
+ // ── dist discovery + static origin (shared by both runners) ──────────────────
122
+
123
+ async function walkHtml(dir, base = dir) {
124
+ const out = [];
125
+ for (const e of await readdir(dir, { withFileTypes: true })) {
126
+ const abs = join(dir, e.name);
127
+ if (e.isDirectory()) out.push(...await walkHtml(abs, base));
128
+ else if (e.name.endsWith(".html")) out.push(relative(base, abs).replace(/\\/g, "/"));
129
+ }
130
+ return out;
131
+ }
132
+
133
+ const MIME = {
134
+ ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "application/javascript",
135
+ ".mjs": "application/javascript", ".json": "application/json", ".svg": "image/svg+xml",
136
+ ".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp", ".ico": "image/x-icon",
137
+ ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".xml": "application/xml",
138
+ ".txt": "text/plain", ".webmanifest": "application/manifest+json", ".pdf": "application/pdf",
139
+ };
140
+
141
+ /**
142
+ * Serve `root` over an ephemeral localhost origin. When `inject` is set, HTML
143
+ * responses get axe-core + a runner appended before </body>, and `/__axe-core.js`
144
+ * serves the axe source — used by the tezcatl runner, which cannot inject async JS
145
+ * itself. Returns { origin, close }.
146
+ */
147
+ async function startServer(root, { inject = false, tags = DEFAULT_TAGS } = {}) {
148
+ let axeSrc = "";
149
+ if (inject) {
150
+ const require = createRequire(import.meta.url);
151
+ axeSrc = await readFile(require.resolve("axe-core/axe.min.js"), "utf8");
152
+ }
153
+ const runnerScript =
154
+ `<script src="/__axe-core.js"></script><script>` +
155
+ `window.addEventListener("load",function(){setTimeout(function(){` +
156
+ `axe.run(document,{runOnly:{type:"tag",values:${JSON.stringify(tags)}}}).then(function(r){` +
157
+ `var e=document.createElement("script");e.type="application/json";e.id="__axe_results";` +
158
+ `e.textContent=JSON.stringify({violations:r.violations});document.documentElement.appendChild(e);` +
159
+ `}).catch(function(err){var e=document.createElement("script");e.type="application/json";` +
160
+ `e.id="__axe_results";e.textContent=JSON.stringify({error:String(err)});document.documentElement.appendChild(e);});` +
161
+ `},150);});</script>`;
162
+
163
+ const server = createServer(async (req, res) => {
164
+ try {
165
+ let urlPath = decodeURIComponent((req.url || "/").split("?")[0]);
166
+ if (inject && urlPath === "/__axe-core.js") {
167
+ res.writeHead(200, { "content-type": "application/javascript" });
168
+ return res.end(axeSrc);
169
+ }
170
+ let file = join(root, urlPath);
171
+ if (urlPath.endsWith("/")) file = join(file, "index.html");
172
+ let buf;
173
+ try { buf = await readFile(file); }
174
+ catch { try { buf = await readFile(file + ".html"); file += ".html"; } catch { res.writeHead(404); return res.end("not found"); } }
175
+ const ext = extname(file).toLowerCase();
176
+ if (inject && ext === ".html") {
177
+ let html = buf.toString("utf8");
178
+ html = html.includes("</body>") ? html.replace("</body>", runnerScript + "</body>") : html + runnerScript;
179
+ buf = Buffer.from(html, "utf8");
180
+ }
181
+ res.writeHead(200, { "content-type": MIME[ext] || "application/octet-stream" });
182
+ res.end(buf);
183
+ } catch (e) { res.writeHead(500); res.end(String(e)); }
184
+ });
185
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
186
+ const { port } = server.address();
187
+ return { origin: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(r)) };
188
+ }
189
+
190
+ // ── Runners: page → raw axe violations[] ─────────────────────────────────────
191
+
192
+ async function collectWithPlaywright(pages, { dist, tags }) {
193
+ let chromium, AxeBuilder;
194
+ try {
195
+ ({ chromium } = await import("playwright"));
196
+ ({ default: AxeBuilder } = await import("@axe-core/playwright"));
197
+ } catch (e) {
198
+ throw new Error(
199
+ "playwright runner needs `playwright` + `@axe-core/playwright` installed " +
200
+ "(and `npx playwright install --with-deps chromium`). " + e.message,
201
+ );
202
+ }
203
+ const srv = await startServer(dist, { inject: false });
204
+ const browser = await chromium.launch();
205
+ const out = new Map();
206
+ try {
207
+ const ctx = await browser.newContext();
208
+ for (const page of pages) {
209
+ const pg = await ctx.newPage();
210
+ await pg.goto(`${srv.origin}/${page}`, { waitUntil: "load" });
211
+ const results = await new AxeBuilder({ page: pg }).withTags(tags).analyze();
212
+ out.set(page, results.violations);
213
+ await pg.close();
214
+ }
215
+ } finally {
216
+ await browser.close();
217
+ await srv.close();
218
+ }
219
+ return out;
220
+ }
221
+
222
+ // Run tezcatl async (NOT execFileSync) — the static origin lives on this same event
223
+ // loop, so a blocking child would deadlock its own server. Resolves to trimmed stdout.
224
+ function tezcatl(args) {
225
+ return new Promise((res, rej) => {
226
+ const ch = spawn("tezcatl", args, { stdio: ["ignore", "pipe", "pipe"] });
227
+ let out = "", err = "";
228
+ ch.stdout.on("data", (d) => (out += d));
229
+ ch.stderr.on("data", (d) => (err += d));
230
+ ch.on("error", (e) => rej(new Error(`tezcatl not runnable (on PATH?): ${e.message}`)));
231
+ ch.on("close", (code) => (code === 0 ? res(out.trim()) : rej(new Error(`tezcatl exit ${code}: ${err.trim() || out.trim()}`))));
232
+ });
233
+ }
234
+
235
+ async function collectWithTezcatl(pages, { dist, tags }) {
236
+ const waitMs = Number(process.env.AXE_TEZCATL_WAIT || 3000);
237
+ const readResults = `--eval=(function(){var e=document.getElementById('__axe_results');return e?e.textContent:'';})()`;
238
+ const srv = await startServer(dist, { inject: true, tags });
239
+ const out = new Map();
240
+ try {
241
+ for (const page of pages) {
242
+ const url = `${srv.origin}/${page}`;
243
+ let text = "";
244
+ for (const attempt of [waitMs, waitMs * 2]) { // one retry with a longer settle window
245
+ const raw = await tezcatl([url, `--wait=${attempt}`, readResults]);
246
+ if (raw && raw !== "NORESULT") { text = raw; break; }
247
+ }
248
+ if (!text) throw new Error(`tezcatl: no axe result for ${page} (raise $AXE_TEZCATL_WAIT?)`);
249
+ const parsed = JSON.parse(text);
250
+ if (parsed.error) throw new Error(`axe failed on ${page}: ${parsed.error}`);
251
+ out.set(page, parsed.violations || []);
252
+ }
253
+ } finally {
254
+ await srv.close();
255
+ }
256
+ return out;
257
+ }
258
+
259
+ const RUNNERS = { playwright: collectWithPlaywright, tezcatl: collectWithTezcatl };
260
+
261
+ /**
262
+ * Run the configured runner over `pages` of `dist` and return the summarized report.
263
+ * Exposed for programmatic use (and the kit's own test) in addition to the CLI.
264
+ */
265
+ export async function runAxeGate({ dist, pages, tags = DEFAULT_TAGS, threshold = DEFAULT_THRESHOLD, runner = "playwright" }) {
266
+ const collect = RUNNERS[runner];
267
+ if (!collect) throw new Error(`unknown runner "${runner}" (expected: ${Object.keys(RUNNERS).join(", ")})`);
268
+ const raw = await collect(pages, { dist, tags });
269
+ const pageResults = pages.map((p) => evaluatePage(p, raw.get(p) || [], threshold));
270
+ return summarize(pageResults, { threshold, tags, runner });
271
+ }
272
+
273
+ // ── CLI ──────────────────────────────────────────────────────────────────────
274
+
275
+ async function main() {
276
+ const dist = resolve(process.argv[2] && !process.argv[2].startsWith("--") ? process.argv[2] : process.env.DIST || "dist");
277
+ const exists = async (p) => { try { await access(p); return true; } catch { return false; } };
278
+ if (!(await exists(dist))) { console.error(`✗ axe-gate: ${dist} not found — build first.`); process.exit(2); }
279
+
280
+ const tags = (process.env.AXE_TAGS || DEFAULT_TAGS.join(",")).split(",").map((s) => s.trim()).filter(Boolean);
281
+ const threshold = (process.env.AXE_IMPACT_THRESHOLD || DEFAULT_THRESHOLD).trim();
282
+ if (!IMPACT_ORDER.includes(threshold)) {
283
+ console.error(`✗ axe-gate: $AXE_IMPACT_THRESHOLD must be one of ${IMPACT_ORDER.join("|")} (got "${threshold}")`);
284
+ process.exit(2);
285
+ }
286
+ const runner = (process.env.AXE_RUNNER || "playwright").trim();
287
+ let pages = (process.env.AXE_PAGES || "").split(",").map((s) => s.trim().replace(/^\//, "")).filter(Boolean);
288
+ if (pages.length === 0) pages = (await walkHtml(dist)).sort();
289
+ if (pages.length === 0) { console.error(`✗ axe-gate: no HTML pages found under ${dist}`); process.exit(2); }
290
+
291
+ console.log(`axe-gate: ${runner} runner · ${pages.length} page(s) · tags [${tags.join(", ")}] · block ≥ ${threshold}`);
292
+ const report = await runAxeGate({ dist, pages, tags, threshold, runner });
293
+
294
+ if (process.env.AXE_REPORT) {
295
+ await writeFile(resolve(process.env.AXE_REPORT), JSON.stringify(report, null, 2) + "\n");
296
+ console.log(` ↳ wrote ${process.env.AXE_REPORT}`);
297
+ }
298
+
299
+ for (const p of report.pages) {
300
+ const tally = IMPACT_ORDER.map((l) => `${p.counts[l]} ${l}`).join(", ");
301
+ const mark = p.blocking ? "✗" : "✓";
302
+ console.log(` ${mark} ${p.page} — ${tally}${p.counts.unknown ? `, ${p.counts.unknown} unknown` : ""}`);
303
+ if (p.blocking) {
304
+ for (const lvl of ["critical", "serious", "moderate", "minor"]) {
305
+ for (const v of p.violations[lvl] || []) {
306
+ if (!blocksAt(lvl, threshold)) continue;
307
+ console.error(` [${lvl}] ${v.id} — ${v.help} (${v.nodes} node(s)) ${v.helpUrl}`);
308
+ for (const t of v.targets) console.error(` · ${t}`);
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ console.log("");
315
+ if (!report.passed) {
316
+ console.error(`✗ axe-gate: ${report.blocking} violation(s) at or above "${threshold}" across ${report.pages.length} page(s) (${report.totals.critical} critical, ${report.totals.serious} serious).`);
317
+ process.exit(1);
318
+ }
319
+ console.log(`✓ axe-gate: ${report.pages.length} page(s) clean — 0 violations at or above "${threshold}" (axe ${tags.includes("wcag22aa") ? "WCAG 2.2 A/AA" : "WCAG A/AA"}).`);
320
+ }
321
+
322
+ // Only run the CLI when invoked directly (not when imported by a test).
323
+ if (import.meta.url === `file://${process.argv[1]}`) {
324
+ main().catch((e) => { console.error("✗ axe-gate: error —", e.stack || e.message); process.exit(1); });
325
+ }