// `init` — wire ultra11y into a repo so accessibility is enforced automatically (a git // pre-commit hook and/or a CI job) and not only on demand. Zero dependencies (no husky): // plain POSIX `sh` hooks + a GitHub Actions workflow. The default pre-commit hook audits // the STRICT STAGED SNAPSHOT (`audit --staged`), auto-applies safe fixes (`fix --staged // --write --safe`) and re-stages them, blocking only on issues that need judgment. The // legacy baseline regression gate (`audit --changed --baseline … --fail-on …`, blocking // only NEW findings) stays available via `init --baseline` and the CI workflow. import { writeFileSync, mkdirSync, chmodSync, realpathSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { join, relative, sep } from "node:path"; import { VERSION, type Severity } from "./types.js"; // English --fail-on token written into generated hooks/CI (the tool is English-first; // parseFailOn still accepts both fr and en). const EN_SEV: Record = { bloquant: "blocking", majeur: "major", mineur: "minor" }; export function repoRoot(): string | null { try { return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return null; } } /** Where the running engine lives, as a path to bake into a generated file. Relative to * `root` when the bundle sits inside it (so a committed git hook keeps working after a * clone), absolute otherwise. Callers that need a runnable COMMAND, not a path, should * use `engineInvocation`. */ export function resolveEnginePath(root: string): string { const argv1 = process.argv[1] ?? "scripts/ultra11y.mjs"; try { const abs = realpathSync(argv1); return abs.startsWith(root + sep) ? relative(root, abs) : abs; } catch { return argv1; } } /** The command a human or an agent can paste to run the engine from `root`. * `node ` when the bundle is vendored in the repo (no install, no network), * `npx -y ultra11y` when it lives outside — where a bare path would break the moment the * reader is on another machine. Used by the AGENTS.md block, which is read by agents that * have no skill system and so cannot be handed a resolved path. */ export function engineInvocation(root: string): string { const p = resolveEnginePath(root); return p.startsWith("/") || p.startsWith("..") ? "npx -y ultra11y" : `node ${p}`; } /** POSIX `sh` pre-commit hook (default, strict staged snapshot). Operates on EXACTLY the * staged index blobs (what the commit records, not the working tree): first auto-applies * SAFE deterministic fixes and re-stages them, then blocks the commit only if issues >= * failOn remain — these are the judgment ones (alt text, labels, link purpose) that a * codemod must not guess. `enginePath` resolved at generation time, overridable via * $ULTRA11Y; bypass once with SKIP_A11Y=1. Quiet on a clean commit. */ export function stagedHookScript(enginePath: string, failOn: Severity): string { const sev = EN_SEV[failOn]; return `#!/bin/sh # ultra11y accessibility gate (strict staged snapshot) — generated by \`ultra11y init --hook\`. # Operates on EXACTLY the staged index blobs (what the commit records, not the working # tree): first auto-applies SAFE deterministic fixes and re-stages them, then blocks the # commit only if issues >= ${sev} remain that need judgment (alt text, link purpose, # labels). Bypass once with: SKIP_A11Y=1 git commit ... [ -n "$SKIP_A11Y" ] && exit 0 ULTRA11Y=\${ULTRA11Y:-'${enginePath}'} command -v node >/dev/null 2>&1 || { echo "ultra11y: node not found — skipping a11y gate." >&2; exit 0; } # 1) Auto-apply safe deterministic fixes to the staged snapshot and re-stage them # (silent no-op when nothing is auto-fixable; warnings, if any, go to stderr). node "$ULTRA11Y" fix --staged --write --safe >/dev/null # 2) Gate: allow the commit unless issues >= ${sev} remain (these need judgment). node "$ULTRA11Y" audit --staged --fail-on ${sev} >/dev/null 2>&1 && exit 0 echo "ultra11y: accessibility issues >= ${sev} remain in staged changes — they need judgment:" >&2 node "$ULTRA11Y" audit --staged --fail-on ${sev} >&2 echo " Apply the real fix on the staged files (or invoke the ultra11y skill), re-stage, and commit again." >&2 echo " Bypass once with: SKIP_A11Y=1 git commit ..." >&2 exit 1 `; } /** POSIX `sh` pre-commit hook (baseline regression gate — opt-in via `init --baseline`). * `enginePath` is resolved at generation time; overridable at runtime via $ULTRA11Y. * Bypass once with SKIP_A11Y=1. */ export function hookScript(enginePath: string, failOn: Severity): string { return `#!/bin/sh # ultra11y accessibility regression gate — generated by \`ultra11y init --hook\`. # Blocks a commit only on NEW non-conformities >= ${EN_SEV[failOn]} in staged changes # (vs audits/baseline.json). Bypass once with: SKIP_A11Y=1 git commit ... [ -n "$SKIP_A11Y" ] && exit 0 ULTRA11Y=\${ULTRA11Y:-'${enginePath}'} command -v node >/dev/null 2>&1 || { echo "ultra11y: node not found — skipping a11y gate." >&2; exit 0; } if ! node "$ULTRA11Y" audit --changed --baseline audits/baseline.json --fail-on ${EN_SEV[failOn]}; then echo "ultra11y: new accessibility regression in staged changes (>= ${EN_SEV[failOn]})." >&2 echo " Fix it, run: node \\"$ULTRA11Y\\" fix --changed --write" >&2 echo " or bypass once with: SKIP_A11Y=1 git commit ..." >&2 exit 1 fi exit 0 `; } /** GitHub Actions workflow auditing the PR diff against the committed baseline. * * It consumes the SHIPPED composite action (`maxgfr/ultra11y`) rather than inlining a * `node … audit` line, so a user's workflow gets the whole surface — SARIF for inline * annotations on the diff, `::error::` fallback annotations, a job summary — instead of a * bare exit code. `enginePath` stays a parameter for the local/vendored case: a repo that * carries the bundle itself can point the run at it and skip the action entirely. */ export function ciWorkflow(enginePath: string, failOn: Severity): string { return `name: a11y # Generated by \`ultra11y init --ci\`. Fails only on NEW non-conformities >= ${EN_SEV[failOn]} # introduced by the PR (vs audits/baseline.json) — not the existing backlog. # # Findings are reported IN the pull request: SARIF → code scanning puts each one on the line # of code that caused it, and \`::error::\` annotations cover repositories without Advanced # Security. Set \`comment: 'true'\` to also get a sticky summary comment (needs # \`pull-requests: write\`), and \`standard: 'rgaa'\` to report against a country standard. # # The action is PINNED to the engine version that generated this file, so a CI run stays # reproducible. \`maxgfr/ultra11y@v${VERSION.split(".")[0]}\` also resolves (a moving major # tag) if you would rather track the major line and take fixes automatically. on: pull_request: permissions: contents: read security-events: write # SARIF upload; drop it and the annotations still report # pull-requests: write # uncomment together with \`comment: 'true'\` below jobs: ultra11y: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: fetch-depth: 0 # the diff gate needs the base ref - uses: maxgfr/ultra11y@v${VERSION} with: since: auto # the PR's base branch baseline: audits/baseline.json fail-on: ${EN_SEV[failOn]} # standard: rgaa # comment: 'true' # # Page-by-page, with a real browser (computed contrast, focus, zoom, reflow). # Every scanned page is also persisted to .ultra11y/pages/ and folded into the # audit, which is what lets a page be CONFORMING at all — and the per-page # dossiers (one sheet per page, with its screenshot) land in the artifact. # start: npm run start # wait-on: http://localhost:3000 # urls: http://localhost:3000 http://localhost:3000/contact # # …or let the page list come from the site itself: # sitemap: http://localhost:3000/sitemap.xml # crawl: http://localhost:3000 # crawl-depth / crawl-max bound it # # …or \`sample: 'true'\` to scan the sample declared in .ultra11yrc.json # (\`ultra11y pages discover --write\` builds that block for you). # # Vendored alternative — no marketplace action, just the bundle in this repo: # - run: node "${enginePath}" audit --since "origin/\${{ github.base_ref }}" --baseline audits/baseline.json --fail-on ${EN_SEV[failOn]} `; } export function writeHook(root: string, enginePath: string, failOn: Severity, mode: "staged" | "baseline" = "staged"): string { const dir = join(root, ".git", "hooks"); mkdirSync(dir, { recursive: true }); const path = join(dir, "pre-commit"); writeFileSync(path, mode === "baseline" ? hookScript(enginePath, failOn) : stagedHookScript(enginePath, failOn)); chmodSync(path, 0o755); return path; } export function writeCi(root: string, enginePath: string, failOn: Severity): string { const dir = join(root, ".github", "workflows"); mkdirSync(dir, { recursive: true }); const path = join(dir, "a11y.yml"); writeFileSync(path, ciWorkflow(enginePath, failOn)); return path; }