ultra11y 2.22.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 (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/docker/Dockerfile +7 -0
  4. package/docker/package.json +9 -0
  5. package/docker/runner.mjs +23 -0
  6. package/package.json +73 -0
  7. package/scripts/bench.mjs +151 -0
  8. package/scripts/build-act-corpus.mjs +143 -0
  9. package/scripts/build-adjudication.mjs +503 -0
  10. package/scripts/build-pack-rgaa.mjs +397 -0
  11. package/scripts/build-standards.mjs +548 -0
  12. package/scripts/copy-bundle.mjs +39 -0
  13. package/scripts/import-pack.mjs +107 -0
  14. package/scripts/sync-engine.mjs +84 -0
  15. package/scripts/sync-version.mjs +43 -0
  16. package/scripts/ultra11y.mjs +52739 -0
  17. package/scripts/vendor/act-testcases.json +12297 -0
  18. package/scripts/vendor/rgaa/SOURCE.json +8 -0
  19. package/scripts/vendor/rgaa/criteres.json +4144 -0
  20. package/scripts/vendor/rgaa/glossaire.json +480 -0
  21. package/scripts/vendor/wcag-2.2-fr.json +82 -0
  22. package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
  23. package/scripts/vendor/wcag-2.2-sc.json +574 -0
  24. package/scripts/verify-rgaa-source.mjs +126 -0
  25. package/scripts/verify-skill-bundle.mjs +91 -0
  26. package/skills/review-a11y/SKILL.md +123 -0
  27. package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
  28. package/skills/ultra11y/SKILL.md +292 -0
  29. package/skills/ultra11y/references/act.md +163 -0
  30. package/skills/ultra11y/references/adjudication.md +599 -0
  31. package/skills/ultra11y/references/audit.md +100 -0
  32. package/skills/ultra11y/references/authoring.md +37 -0
  33. package/skills/ultra11y/references/automation.md +113 -0
  34. package/skills/ultra11y/references/correction.md +41 -0
  35. package/skills/ultra11y/references/criteria.md +129 -0
  36. package/skills/ultra11y/references/cross-file.md +82 -0
  37. package/skills/ultra11y/references/dynamic.md +152 -0
  38. package/skills/ultra11y/references/false-positives.md +81 -0
  39. package/skills/ultra11y/references/fix.md +46 -0
  40. package/skills/ultra11y/references/focus-and-logic.md +64 -0
  41. package/skills/ultra11y/references/forbidden-patterns.md +61 -0
  42. package/skills/ultra11y/references/forms-and-errors.md +147 -0
  43. package/skills/ultra11y/references/guidance.md +55 -0
  44. package/skills/ultra11y/references/judgment.md +90 -0
  45. package/skills/ultra11y/references/media-and-motion.md +116 -0
  46. package/skills/ultra11y/references/methodology.md +72 -0
  47. package/skills/ultra11y/references/naming.md +117 -0
  48. package/skills/ultra11y/references/orchestration.md +44 -0
  49. package/skills/ultra11y/references/packs.md +193 -0
  50. package/skills/ultra11y/references/prd.md +104 -0
  51. package/skills/ultra11y/references/rendered.md +110 -0
  52. package/skills/ultra11y/references/scale.md +49 -0
  53. package/skills/ultra11y/references/standards.md +97 -0
  54. package/skills/ultra11y/references/structure.md +138 -0
  55. package/skills/ultra11y/references/verify.md +88 -0
  56. package/skills/ultra11y/references/widgets.md +106 -0
  57. package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
  58. package/src/act.ts +441 -0
  59. package/src/adjudicate.ts +630 -0
  60. package/src/aria.ts +230 -0
  61. package/src/audit.ts +396 -0
  62. package/src/auditor.ts +399 -0
  63. package/src/autofill.ts +102 -0
  64. package/src/axe-map.ts +294 -0
  65. package/src/baseline.ts +75 -0
  66. package/src/capture.ts +182 -0
  67. package/src/check.ts +243 -0
  68. package/src/cli.ts +1927 -0
  69. package/src/color.ts +102 -0
  70. package/src/config.ts +182 -0
  71. package/src/crawl.ts +75 -0
  72. package/src/criteria.ts +183 -0
  73. package/src/data/adjudication.json +770 -0
  74. package/src/data/guidance/rgaa.json +2395 -0
  75. package/src/data/standards/rgaa.glossary.json +478 -0
  76. package/src/data/standards/rgaa.json +3454 -0
  77. package/src/data/wcag-universe.json +530 -0
  78. package/src/data/wcag.json +1178 -0
  79. package/src/discover.ts +203 -0
  80. package/src/fix/codemods.ts +76 -0
  81. package/src/fix/diff.ts +64 -0
  82. package/src/fix/edits.ts +126 -0
  83. package/src/fix.ts +215 -0
  84. package/src/gh.ts +214 -0
  85. package/src/glob.ts +206 -0
  86. package/src/graph/build.ts +114 -0
  87. package/src/graph/engine-resolve.ts +0 -0
  88. package/src/graph/graph.ts +126 -0
  89. package/src/graph/imports.ts +359 -0
  90. package/src/graph/resolve.ts +73 -0
  91. package/src/graph/tsconfig.ts +113 -0
  92. package/src/grounding.ts +119 -0
  93. package/src/guidance/index.ts +45 -0
  94. package/src/guidance/types.ts +36 -0
  95. package/src/init.ts +113 -0
  96. package/src/mcp/handlers.ts +388 -0
  97. package/src/mcp/http.ts +260 -0
  98. package/src/mcp/prompts.ts +183 -0
  99. package/src/mcp/protocol.ts +187 -0
  100. package/src/mcp/resources.ts +136 -0
  101. package/src/mcp/server.ts +228 -0
  102. package/src/mcp/stdio.ts +119 -0
  103. package/src/mcp/tools.ts +347 -0
  104. package/src/messages.ts +1011 -0
  105. package/src/name.ts +174 -0
  106. package/src/orchestrate-templates.ts +231 -0
  107. package/src/orchestrate.ts +186 -0
  108. package/src/output.ts +108 -0
  109. package/src/pack.ts +187 -0
  110. package/src/parse/html.ts +258 -0
  111. package/src/parse/jsx-ast.ts +67 -0
  112. package/src/parse/jsx-bridge.ts +247 -0
  113. package/src/parse/jsx.ts +19 -0
  114. package/src/parse/source.ts +75 -0
  115. package/src/prd.ts +406 -0
  116. package/src/project-lock.ts +42 -0
  117. package/src/render.ts +307 -0
  118. package/src/report.ts +399 -0
  119. package/src/rules/colors.ts +125 -0
  120. package/src/rules/cross-registry.ts +264 -0
  121. package/src/rules/cross-rule.ts +83 -0
  122. package/src/rules/forms.ts +590 -0
  123. package/src/rules/frames.ts +33 -0
  124. package/src/rules/headings.ts +227 -0
  125. package/src/rules/images.ts +200 -0
  126. package/src/rules/links.ts +193 -0
  127. package/src/rules/mandatory.ts +173 -0
  128. package/src/rules/multimedia.ts +77 -0
  129. package/src/rules/navigation.ts +190 -0
  130. package/src/rules/presentation.ts +75 -0
  131. package/src/rules/registry.ts +57 -0
  132. package/src/rules/rule.ts +122 -0
  133. package/src/rules/scripts-aria.ts +636 -0
  134. package/src/rules/tables.ts +264 -0
  135. package/src/rules/timing.ts +55 -0
  136. package/src/sample.ts +176 -0
  137. package/src/scan-local.ts +866 -0
  138. package/src/scan.ts +538 -0
  139. package/src/standards/derive.ts +195 -0
  140. package/src/standards/index.ts +31 -0
  141. package/src/standards/pack-rules.ts +111 -0
  142. package/src/standards/pack.ts +81 -0
  143. package/src/standards/registry.ts +100 -0
  144. package/src/standards/types.ts +202 -0
  145. package/src/standards/validate.ts +544 -0
  146. package/src/standards/vocabulary.ts +75 -0
  147. package/src/types.ts +355 -0
  148. package/src/util.ts +32 -0
  149. package/src/vendor/codeindex-engine.d.mts +1009 -0
  150. package/src/vendor/codeindex-engine.mjs +13701 -0
  151. package/src/vendor/engine.meta.json +9 -0
  152. package/src/verify.ts +275 -0
  153. package/src/wcag.ts +128 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1927 @@
1
+ import { realpathSync, writeFileSync, mkdirSync, existsSync, readFileSync, appendFileSync } from "node:fs";
2
+ import { join, relative, sep, dirname } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { VERSION, type Lang, type AuditResult, type DynamicResult, type SampleConfig } from "./types.js";
5
+ import { runAudit } from "./audit.js";
6
+ import { writeReport, untestedNeedsRendering, partialAuditBanner } from "./report.js";
7
+ import { writePrd, prdUnits, type PrdFormat } from "./prd.js";
8
+ import { ghAvailable, pushIssues, pushSingleIssue } from "./gh.js";
9
+ import {
10
+ detectFrameworks,
11
+ renderPlan,
12
+ ssrHarness,
13
+ captureSetup,
14
+ captureSetupPlan,
15
+ detectTestRunner,
16
+ parseStorybookIndex,
17
+ storyProvenance,
18
+ type Detection,
19
+ } from "./render.js";
20
+ import { computeCaptureCoverage, parseCaptureProvenance, formatCaptureComment, type CaptureEntry } from "./capture.js";
21
+ import { buildGraphStreaming } from "./graph/build.js";
22
+ import { discover } from "./discover.js";
23
+ import { toPosix, GRAPH_ONLY_EXT } from "./glob.js";
24
+ import { runCriteria, renderCriteriaReference } from "./criteria.js";
25
+ import { checkReport, checkSemantic } from "./check.js";
26
+ import { buildWorklist, writeWorklist, applyVerdicts, VERIFY_MAX, type VerifyItem } from "./verify.js";
27
+ import { groundItems } from "./grounding.js";
28
+ import { buildAdjudicationWorklist, writeAdjudication, applyAdjudication, type AdjudicationFile } from "./adjudicate.js";
29
+ import { runScan, runScanMany, runCrawlScan, runSampleScan, mergeDynamic, cleanDynamic, dockerAvailable } from "./scan.js";
30
+ import { runScanLocal, runScanManyLocal, runCrawlScanLocal, runSampleScanLocal, localAvailable } from "./scan-local.js";
31
+ import { validateSample, lintSample, kindLabel } from "./sample.js";
32
+ import { runFix, fixSummary } from "./fix.js";
33
+ import { diffAgainstBaseline, baselineSummary, parseFailOn, findingsAtOrAbove } from "./baseline.js";
34
+ import { repoRoot, writeHook, writeCi } from "./init.js";
35
+ import { auditSummary, captureCoverageSummary } from "./output.js";
36
+ import { resolveStandard, getPack, isCore, type StandardId } from "./standards/index.js";
37
+ import { loadRuntimeStandards, loadConfig } from "./config.js";
38
+ import { runPackCheck, packScaffold } from "./pack.js";
39
+ import { listPhases, orchestrateRun, PHASES } from "./orchestrate.js";
40
+ import { readStdin, readText } from "./util.js";
41
+ import { runStdioServer } from "./mcp/stdio.js";
42
+ import { startHttpServer } from "./mcp/http.js";
43
+
44
+ const HELP = `ultra11y v${VERSION}
45
+ Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or
46
+ author/review accessible markup (native-HTML-first). A deterministic, install-free
47
+ static engine plus your judgment, with check/verify gates against hallucinated
48
+ non-conformities. RGAA (France) and other country standards are pluggable packs
49
+ (--standard <pack>); WCAG is the worldwide core.
50
+
51
+ Usage:
52
+ ultra11y audit <globs… | -> [--out <dir>] [--include <glob>] [--exclude <glob>] [--ext <list>] [--jsx] [--graph] [--json] [--lang auto|en|fr] [--no-default-excludes]
53
+ ultra11y audit [--changed | --since <ref> | --staged] [--max-files <n>] [--dedup exact|normalized|off] [--baseline <file>] [--fail-on blocking|major|minor]
54
+ ultra11y audit [--captures <dir>] [--no-captures] [--require-captures] (rendered-DOM captures: audit real HTML, gate blind-spot components)
55
+ ultra11y report --in <audit.json> [--out <dir>] [--standard <pack>] [--lang auto|en|fr]
56
+ ultra11y prd --in <audit.json> [--out <dir>] [--split criterion] [--format audit|doc|remediation] [--no-technical] [--standard <pack>] [--gh-issues | --gh-single] [--lang auto|en|fr]
57
+ ultra11y render [<dir>] [--scaffold | --setup | --coverage | --storybook] [--captures <dir>] [--out <file>] [--json] [--lang auto|en|fr]
58
+ ultra11y criteria [<sc>] [--list] [--standard <pack> [--theme <N>]] [--generate] [--json] [--lang auto|en|fr]
59
+ ultra11y check --report <md> [--standard <pack>] [--in <audit.json>] [--semantic [--verdicts <file>]] [--quiet] [--json]
60
+ ultra11y verify --report <md> [--standard <pack>] [--semantic] [--apply <verdicts.json>] [--max-verify <n>] [--out <dir>] [--json]
61
+ ultra11y verify --report <md> --in <audit.json> --manual [--out <dir>] [--json] (adjudicate the manual criteria)
62
+ ultra11y verify --apply <adjudication.json> --in <audit.json> [--out <dir>] (fold the adjudication into the audit)
63
+ ultra11y orchestrate --run <dir> [--phase adjudicate|verify-report] [--eco] [--list] [--lang auto|en|fr]
64
+ ultra11y fix <globs… | -> [--write] [--iterate] [--changed | --since <ref> | --staged] [--safe] [--include <glob>] [--exclude <glob>] [--ext <list>] [--only <ids>] [--jsx] [--json] [--lang auto|en|fr]
65
+ ultra11y init [--hook] [--ci] [--baseline] [--fail-on blocking|major|minor]
66
+ ultra11y pack check <pack.json> [--guidance <g.json>] [--json] | pack scaffold
67
+ ultra11y scan <url|file…> [--runtime auto|local|docker] [--cwd <dir>] [--storage-state <file>] [--no-interact] [--interact-clicks] [--merge <audit.json>] [--out <dir>] [--json]
68
+ ultra11y scan --sample [--runtime …] [--cwd <dir>] [--storage-state <file>] [--merge <audit.json>] [--json] (scan the .ultra11yrc.json page sample)
69
+ ultra11y scan --sitemap <url> | --crawl <url> [--depth <n>] [--max <n>] [--runtime …] [--cwd <dir>] [--merge <audit.json>] [--json]
70
+ ultra11y scan --clean (remove the dynamic-tier Docker image + temp contexts)
71
+ ultra11y sample check [--standard <pack>] [--json] (lint the .ultra11yrc.json page sample vs the standard's required page kinds)
72
+
73
+ Commands:
74
+ audit Run the static engine over the inputs (files/globs, or '-' for stdin)
75
+ and emit an AuditResult JSON keyed by WCAG 2.2 success criteria
76
+ (consumed by 'report'). Without --json, prints a summary in --lang
77
+ (default auto: repo <html lang> → the active standard's default locale
78
+ → English). The engine decides the machine-detectable criteria; the AI
79
+ agent adjudicates the judgment ones (verify --manual, gated) and the
80
+ scan tier decides the needs-rendering ones.
81
+ report Render an AuditResult into a dated WCAG 2.2 AA compliance report
82
+ (audits/wcag-YYYY-MM-DD.md): metadata, per-guideline synthesis table,
83
+ non-conformities by priority, conforming + not-applicable lists.
84
+ --standard <pack> writes a derived report for a country standard
85
+ (e.g. --standard rgaa → audits/rgaa-YYYY-MM-DD.md).
86
+ prd Turn an AuditResult into an AUDITOR conformance backlog
87
+ (audits/prd-YYYY-MM-DD.md), one entry per criterion rendered with the
88
+ active standard's vocabulary (RGAA "Thématique/Critère/Test", WCAG core
89
+ "Principle·Guideline/Success criterion/Technique") — theme, criterion +
90
+ official wording, test(s), WCAG mapping + level, finding, expected state,
91
+ verification. --split criterion writes one file per criterion;
92
+ --gh-issues files one de-duplicated GitHub issue per criterion, or
93
+ --gh-single files the whole audit as a single issue (gh CLI).
94
+ render Get RENDERED HTML to audit (so component libraries like DSFR are
95
+ checked as the HTML they emit, not their JSX sources): detect the
96
+ framework and print the build→audit recipe, or --scaffold a
97
+ react-dom/server SSR snapshot harness to fill in. --setup installs the
98
+ zero-touch test-render capture harvester (one setupFiles line → every
99
+ component your tests render is snapshotted to .ultra11y/captures and
100
+ audited). --coverage reports which components have a rendered capture vs
101
+ which are still opaque-source-only blind spots. --storybook attributes
102
+ per-story HTML (via a storybook-static index) back to source components.
103
+ Then audit the produced HTML, and use scan for the needs-rendering criteria.
104
+ criteria Look up the reference offline. Core: one WCAG success criterion
105
+ (criteria 1.4.3) or the full list grouped by guideline (--list).
106
+ --standard <pack>: a pack criterion, a pack theme (--theme N), or its
107
+ theme list. Carries the WCAG↔pack cross-refs + automatability class.
108
+ check Integrity gate on a produced report: every cited criterion resolves,
109
+ every NA is justified, sections + pass-rate maths are well-formed.
110
+ --standard tells it which id grammar/registry to validate against.
111
+ verify Adversarial claim↔criterion worklist for the report's non-conformities,
112
+ then (--apply) gate on refuted/unsupported findings.
113
+ orchestrate Emit the run's multi-agent orchestration from its CURRENT worklists:
114
+ one launchable Workflow script per ready phase (adjudicate over
115
+ ADJUDICATE.todo.json, verify-report over VERIFY.todo.json), the
116
+ agents/<role>.md dispatch contracts they reference, and a sequential
117
+ RUNBOOK.md fallback — absolute paths and the real worklist ids baked
118
+ in. Subagents only RETURN verdict fragments; you (the caller) stay
119
+ the sole writer and fold them via verify --apply. --eco emits only
120
+ the RUNBOOK + contracts (the explicit low-token path); --list prints
121
+ the phases and their readiness as JSON. Re-run after a worklist
122
+ changes — emission is deterministic and idempotent.
123
+ fix Put the fixes in place (hybrid, native-first): apply deterministic
124
+ codemods (tabindex, redundant role, viewport zoom), insert fill-in
125
+ placeholders (alt/lang/title TODO) for the agent to complete, and list
126
+ judgment-only proposals. --dry-run (default) prints a diff; --write
127
+ applies, but only after a re-audit proves no new NC; on real-AST JSX
128
+ only jsxSafe codemods apply (never name-rewriting). --iterate loops to a fixpoint.
129
+ init Wire ultra11y into the repo (zero-dep, no husky). Default --hook is a git
130
+ pre-commit gate over the STRICT STAGED SNAPSHOT: audits exactly the staged
131
+ index blobs, auto-applies safe fixes (fix --staged --write --safe) and
132
+ re-stages them, blocking only on issues that need judgment. Opt into the
133
+ legacy regression gate with --baseline (audit --changed vs a committed
134
+ audits/baseline.json, only NEW NCs fail) — also used by the --ci job
135
+ (audit --since the PR base ref).
136
+ pack Author/verify a runtime standards pack. 'pack check <pack.json>
137
+ [--guidance <g.json>]' runs the validator + guidance gate (every
138
+ criterion maps to well-formed WCAG SCs, every guidance entry resolves to
139
+ a real criterion, every code example parses) — the anti-hallucination
140
+ gate for AI-ingested packs. 'pack scaffold' prints a blank pack to fill.
141
+ Load packs at audit/report time with --pack (or .ultra11yrc.json).
142
+ scan OPTIONAL dynamic tier: run axe-core in a headless browser to decide the
143
+ needs-rendering criteria the static engine can't — computed contrast
144
+ (1.4.3), 320px reflow (1.4.10) — over a URL or HTML file. The local
145
+ runtime (--runtime local, default when Playwright resolves from --cwd;
146
+ no Docker) additionally probes focus visibility (2.4.7), 200% zoom
147
+ (1.4.4), text spacing (1.4.12), content on hover (1.4.13) and target
148
+ size (2.5.8), and accepts --storage-state for authenticated pages.
149
+ By default the local runtime is STATEFUL: it types long values into
150
+ inputs and flags any that clip at 320px/200%/text-spacing (1.4.10/1.4.4/
151
+ 1.4.12 — esp. inputs inside table cells), opens closed dialogs to re-check
152
+ focus, and drives SAFE interactions (fill / toggle / click type=button —
153
+ never a link, submit or navigation; destructive-named buttons are never
154
+ clicked) to spot content updated outside a live region (4.1.3). On an
155
+ AUTHENTICATED scan (--storage-state) the button clicks are skipped too,
156
+ unless --interact-clicks opts back in. --no-interact disables all of that
157
+ (pristine-page probes only). --merge folds the findings into a static
158
+ AuditResult (manual → C/NC).
159
+ --sitemap/--crawl scan many pages (every sitemap URL, or same-origin
160
+ links BFS-crawled from a start URL) and aggregate the findings.
161
+ --sample scans the NORMATIVE page sample declared in .ultra11yrc.json
162
+ (representative pages + transverse elements); each page's own
163
+ storage-state overrides --storage-state, and the report groups the
164
+ findings « Constats par page ».
165
+ sample Normative page-sample (échantillon) helper. 'sample check' lints the
166
+ .ultra11yrc.json sample block against the active standard's required
167
+ page kinds (RGAA: accueil, contact, mentions légales, déclaration
168
+ d'accessibilité, plan du site, aide, authentification, pages
169
+ représentatives + éléments transverses) — advisory, never a gate.
170
+
171
+ Options:
172
+ --out <dir> output dir (report/prd/scan default: audits); for audit, persist
173
+ audit-latest.json here — a plain audit writes nothing without it.
174
+ For audit, a value ending in .json is a FILE target (written exactly);
175
+ the path actually written is echoed on stderr
176
+ --in <file> report: the AuditResult JSON to render ('-' for stdin)
177
+ --include <glob> audit/fix: only include paths matching (comma-separated)
178
+ --exclude <glob> audit/fix: skip paths matching (comma-separated)
179
+ --ext <list> audit/fix: extra file extensions to walk (e.g. .twig,.erb);
180
+ .html/.htm/.xhtml/.jsx/.tsx/.vue/.svelte/.astro are built-in
181
+ --no-default-excludes audit/fix: also audit test/spec/story/__tests__ markup
182
+ (excluded by default; logged, never a silent drop)
183
+ --jsx audit/fix: force JSX/TSX parsing for inputs of any extension
184
+ --graph audit: also resolve imports + run cross-file rules (alias --cross-file)
185
+ --cross-file audit: alias of --graph
186
+ --changed audit/fix: only files changed vs HEAD (git; staged+unstaged+untracked, working tree)
187
+ --since <ref> audit/fix: only files changed vs the given git ref
188
+ --staged audit/fix: only STAGED files, read from the index blob (exact commit snapshot; wins over --changed)
189
+ --max-files <n> audit: cap canonical files audited (logged truncation, no silent drop)
190
+ --dedup <mode> audit: collapse identical files — exact|normalized|off (default: exact)
191
+ --standard <pack> report/prd/criteria/check/verify: WCAG core (default) or a pack
192
+ key (rgaa, …); contribute a country via a pack (see CONTRIBUTING.md)
193
+ --pack <paths> load external standards pack(s) at runtime (no rebuild): a pack JSON
194
+ file, or a dir with pack.json (+ glossary.json/guidance.json);
195
+ comma-separated, validated before use (see references/packs.md)
196
+ --override --pack: allow a runtime pack key to replace a built-in/loaded standard
197
+ --guidance <file> pack check: the guidance dataset JSON to gate alongside the pack
198
+ --format <mode> prd: 'audit' (default) emits the auditor conformance block (per
199
+ the active standard's vocabulary) for the backlog AND GitHub issues;
200
+ 'doc' emits a product-requirements document (epics, user stories,
201
+ Given/When/Then); 'remediation' emits the legacy dev fix backlog
202
+ --split <mode> prd: split the backlog — currently only 'criterion' (one file per criterion)
203
+ --no-technical prd (audit format): omit the technical ticket sections (Partie
204
+ technique + Contexte de reproduction) for a pure-auditor block
205
+ --gh-issues prd: also create one GitHub issue per criterion via the gh CLI (opt-in)
206
+ --gh-single prd: file the whole audit as ONE consolidated GitHub issue (opt-in; wins over --gh-issues)
207
+ --scaffold render: write an SSR-snapshot harness (default: ultra11y-render.tsx)
208
+ --setup render: install the zero-touch test-render capture harvester (.ultra11y/capture-setup.mjs) + print the runner wiring
209
+ --coverage render: report rendered-capture coverage (covered vs blind-spot components); with --json emits the coverage object
210
+ --storybook render: attribute per-story HTML (via storybook-static index.json) into .ultra11y/captures (point the HTML dir with --captures)
211
+ --captures <dir> audit/render: rendered-capture dir to ingest (default: .ultra11y/captures)
212
+ --no-captures audit: do NOT auto-detect/ingest the .ultra11y/captures dir
213
+ --require-captures audit: gate — fail if any opaque/control component lacks a rendered capture (implies --graph)
214
+ --write fix: apply fixes to disk (default is a dry-run diff)
215
+ --iterate fix: with --write, re-audit + re-apply mechanical fixes until stable (bounded)
216
+ --dry-run fix: preview only — never write (this is the default)
217
+ --safe fix: apply only genuinely-automatic codemods (skip TODO placeholders / judgment proposals)
218
+ --only <ids> fix: limit auto-fixes to these rule ids (comma-separated)
219
+ --baseline <file> audit/init: regression-gate vs / write this baseline AuditResult
220
+ --fail-on <sev> audit/init: gate severity — blocking|major|minor (fr aliases accepted) (default: blocking)
221
+ --hook init: write a git pre-commit accessibility gate (staged snapshot + auto-fix by default)
222
+ --ci init: write a GitHub Actions accessibility gate
223
+ --report <file> check/verify: the report markdown to gate
224
+ --theme <N> criteria: with --standard <pack>, list the pack's theme N
225
+ --list criteria: print the WCAG success criteria grouped by guideline
226
+ --generate criteria: emit the bundled references/criteria.md (WCAG 2.2 AA)
227
+ --apply <file> verify: reduce a filled verdicts file to a pass/fail gate
228
+ (requires --report — coverage is re-derived from the report, uncapped)
229
+ --max-verify <n> verify: cap the worklist size; 0 = no cap (default: 40)
230
+ --verdicts <file> check --semantic: the adjudicated verdicts artifact
231
+ (default: VERIFY.todo.json next to the report)
232
+ --run <dir> orchestrate: the run dir holding the worklists (ADJUDICATE.todo.json,
233
+ VERIFY.todo.json); artifacts land under <dir>/orchestration/
234
+ --phase <name> orchestrate: emit one phase only — adjudicate | verify-report
235
+ (exit 2 with the producing command if its worklist is missing)
236
+ --eco orchestrate: emit only RUNBOOK.md + agents/*.md — the explicit
237
+ sequential low-token path (also what a no-subagent harness follows)
238
+ --merge <file> scan: fold dynamic findings into this AuditResult JSON
239
+ --sitemap <url> scan: scan every URL listed in a sitemap.xml
240
+ --crawl <url> scan: BFS same-origin links from a start URL (served HTML)
241
+ --depth <n> scan: crawl link-hop depth from the start URL (default: 2)
242
+ --max <n> scan: cap on pages scanned (sitemap/crawl) (default: 50)
243
+ --runtime <mode> scan: local (host/target Playwright, no Docker) | docker | auto
244
+ (default: auto — local if Playwright resolves from --cwd, else Docker)
245
+ --local scan: alias of --runtime local
246
+ --docker scan: alias of --runtime docker (built on first use)
247
+ --cwd <dir> scan: --runtime local resolves @playwright/test + @axe-core/playwright
248
+ (and the browser) from here (e.g. --cwd packages/app)
249
+ --storage-state <file> scan: --runtime local — Playwright storageState JSON for
250
+ authenticated pages (e.g. test-results/.auth/user.json)
251
+ --no-interact scan: --runtime local — disable the STATEFUL probes (fill inputs,
252
+ open dialogs, live-region). Default is ON; the interactions are
253
+ strictly non-navigating (fill text inputs, toggle checkbox/radio,
254
+ click button[type=button] — never a link/submit/navigation, and
255
+ never a button whose accessible name matches a destructive verb:
256
+ supprimer, retirer, effacer, envoyer, valider, confirmer, payer,
257
+ delete, remove, send, submit, confirm, pay, …) and restore page
258
+ state, asserting location.href is unchanged after each action
259
+ --interact-clicks scan: --runtime local — allow the live-region probe's
260
+ button[type=button] clicks on an AUTHENTICATED scan
261
+ (--storage-state). Skipped by default there: a click can trigger
262
+ a server mutation (delete/send) invisible to the location.href
263
+ assertion. Unauthenticated scans keep clicks on regardless; the
264
+ destructive-name skip above applies in every case
265
+ --sample scan: scan the NORMATIVE page sample from .ultra11yrc.json (its
266
+ sample.pages), per-page storage-state overriding --storage-state,
267
+ aggregating one result with per-page provenance for the report
268
+ --clean scan: remove the dynamic-tier image + temp contexts, then exit
269
+ --semantic verify: fold the support-check into one pass
270
+ check: engage the semantic gate — requires an adjudicated verdicts
271
+ artifact (fails closed when absent) and re-grounds every passing
272
+ verdict content-level against the cited source
273
+ --manual verify: with --in <audit.json>, emit an adjudication worklist over the
274
+ audit's residual (judgment / needs-rendering) criteria for the agent to rule
275
+ --lang auto|en|fr output language (default: auto — conversation/repo
276
+ language: an AI caller should pass --lang explicitly to match the
277
+ chat; unset resolves repo <html lang> → standard's default locale → en)
278
+ --json machine-readable output
279
+ --quiet check: exit code only, no output
280
+ -h, --help show this help
281
+ -v, --version print version
282
+
283
+ Data: WCAG 2.2 © W3C (W3C Document License). RGAA 4.1.2 pack © DINUM, Licence Ouverte / Etalab 2.0 (see NOTICE).`;
284
+
285
+ const COMMANDS = ["audit", "report", "prd", "render", "criteria", "check", "verify", "scan", "sample", "fix", "init", "pack", "orchestrate", "mcp"] as const;
286
+ type Command = (typeof COMMANDS)[number];
287
+
288
+ function isCommand(s: string | undefined): s is Command {
289
+ return !!s && (COMMANDS as readonly string[]).includes(s);
290
+ }
291
+
292
+ const VALUE_FLAGS = new Set([
293
+ "out",
294
+ "in",
295
+ "include",
296
+ "exclude",
297
+ "ext",
298
+ "report",
299
+ "theme",
300
+ "apply",
301
+ "verdicts",
302
+ "max-verify",
303
+ "lang",
304
+ "merge",
305
+ "sitemap",
306
+ "crawl",
307
+ "depth",
308
+ "max",
309
+ "since",
310
+ "max-files",
311
+ "dedup",
312
+ "only",
313
+ "standard",
314
+ "baseline",
315
+ "fail-on",
316
+ "split",
317
+ "pack",
318
+ "format",
319
+ "guidance",
320
+ "runtime",
321
+ "cwd",
322
+ "storage-state",
323
+ "captures",
324
+ "run",
325
+ "phase",
326
+ // `mcp` only. The flag sets are global, so these are accepted (and warned
327
+ // about, never silently ignored) on every command — like --phase already is.
328
+ "transport",
329
+ "port",
330
+ "bind",
331
+ "allow-origin",
332
+ "max-response-bytes",
333
+ ]);
334
+ // `init` treats --baseline as a boolean selector ("write the baseline"), not a
335
+ // path, so it must NOT consume the following token. audit/fix keep it as a value
336
+ // flag (`--baseline <file>`). Without this split, `init --baseline --hook` swallows
337
+ // --hook, and `init --baseline` never matches the `=== true` selector in cmdInit.
338
+ const INIT_VALUE_FLAGS = new Set([...VALUE_FLAGS].filter((f) => f !== "baseline"));
339
+
340
+ function valueFlagsFor(command: string): ReadonlySet<string> {
341
+ return command === "init" ? INIT_VALUE_FLAGS : VALUE_FLAGS;
342
+ }
343
+
344
+ // Boolean flags documented in HELP (every valid flag that is NOT a value flag).
345
+ // Paired with VALUE_FLAGS this is the full set of recognised long flags, used to
346
+ // warn on unknown/misspelled ones instead of silently accepting them as no-ops.
347
+ const BOOLEAN_FLAGS = new Set([
348
+ "changed",
349
+ "staged",
350
+ "jsx",
351
+ "graph",
352
+ "cross-file",
353
+ "json",
354
+ "quiet",
355
+ "no-default-excludes",
356
+ "no-captures",
357
+ "require-captures",
358
+ "scaffold",
359
+ "storybook",
360
+ "setup",
361
+ "coverage",
362
+ "write",
363
+ "dry-run",
364
+ "iterate",
365
+ "safe",
366
+ "hook",
367
+ "ci",
368
+ "list",
369
+ "generate",
370
+ "semantic",
371
+ "manual",
372
+ "no-technical",
373
+ "gh-issues",
374
+ "gh-single",
375
+ "override",
376
+ "local",
377
+ "docker",
378
+ "no-interact",
379
+ "interact-clicks",
380
+ "clean",
381
+ "sample",
382
+ "eco",
383
+ "help",
384
+ "version",
385
+ "allow-remote",
386
+ "allow-write",
387
+ ]);
388
+ const KNOWN_FLAGS: ReadonlySet<string> = new Set<string>([...VALUE_FLAGS, ...BOOLEAN_FLAGS]);
389
+ // Long flags whose repetition should accumulate (comma-joined) rather than last-wins,
390
+ // so `--include a --include b` keeps both. Non-list value flags stay last-wins.
391
+ const LIST_FLAGS = new Set(["include", "exclude", "ext"]);
392
+
393
+ export interface ParsedArgs {
394
+ command: string;
395
+ positionals: string[];
396
+ flags: Record<string, string | boolean>;
397
+ /** long flags that are not recognised (neither value nor boolean) — main() warns. */
398
+ unknown: string[];
399
+ }
400
+
401
+ export function parseArgs(argv: string[]): ParsedArgs {
402
+ const [command, ...rest] = argv;
403
+ const valueFlags = valueFlagsFor(command ?? "");
404
+ const positionals: string[] = [];
405
+ const flags: Record<string, string | boolean> = {};
406
+ const unknown: string[] = [];
407
+ for (let i = 0; i < rest.length; i++) {
408
+ const a = rest[i]!;
409
+ if (a.startsWith("--")) {
410
+ // Support both `--key value` and `--key=value`. Splitting on the first `=`
411
+ // means `--standard=rgaa` / `--out=audits` are parsed as values, not swallowed
412
+ // whole into a boolean no-op key ("standard=rgaa").
413
+ const eq = a.indexOf("=");
414
+ const key = eq === -1 ? a.slice(2) : a.slice(2, eq);
415
+ const inlineVal = eq === -1 ? undefined : a.slice(eq + 1);
416
+ let val: string | boolean;
417
+ if (inlineVal !== undefined) val = inlineVal;
418
+ else if (valueFlags.has(key)) val = rest[++i] ?? "";
419
+ else val = true;
420
+ const prev = flags[key];
421
+ if (LIST_FLAGS.has(key) && typeof prev === "string" && typeof val === "string") {
422
+ flags[key] = prev ? `${prev},${val}` : val; // accumulate repeated list flags
423
+ } else {
424
+ flags[key] = val;
425
+ }
426
+ if (!KNOWN_FLAGS.has(key)) unknown.push(key);
427
+ } else if (a.startsWith("-") && a !== "-") {
428
+ // No single-dash short flags are defined (only `-` = stdin, handled below), so `-grph`
429
+ // is a typo for `--graph` — set it for backward-compat but surface it as unknown.
430
+ flags[a.slice(1)] = true;
431
+ unknown.push(a.slice(1));
432
+ } else {
433
+ positionals.push(a);
434
+ }
435
+ }
436
+ return { command: command ?? "", positionals, flags, unknown };
437
+ }
438
+
439
+ /** Resolve the output language. `--lang fr|en` explicit always wins. Otherwise
440
+ * (`auto` or the flag absent): the repo's detected language wins if it is fr/en
441
+ * (the majority entry of an audit's `scope.langs`, set by `runAudit`), else the
442
+ * active standard pack's `defaultLocale` if fr/en (the WCAG core has none, so
443
+ * it is skipped), else `en`. The CLI's conversational caller (an AI agent) is
444
+ * expected to pass `--lang` explicitly matching the conversation language —
445
+ * this fallback chain only covers a bare/scripted invocation. */
446
+ function resolveLang(flags: Record<string, string | boolean>, ctx: { audit?: AuditResult; standard?: StandardId } = {}): Lang {
447
+ if (flags.lang === "fr" || flags.lang === "en") return flags.lang;
448
+ const top = ctx.audit?.scope.langs?.[0];
449
+ if (top === "fr" || top === "en") return top;
450
+ const locale = ctx.standard ? getPack(ctx.standard)?.defaultLocale : undefined;
451
+ if (locale === "fr" || locale === "en") return locale;
452
+ return "en";
453
+ }
454
+
455
+ function asList(v: string | boolean | undefined): string[] | undefined {
456
+ return typeof v === "string" && v ? [v] : undefined;
457
+ }
458
+
459
+ /** Read a `--report`/`--in` file, printing a clean CLI error and returning null (so the
460
+ * caller can exit 2) instead of surfacing a raw ENOENT stack trace. */
461
+ function readInputFile(path: string, cmd: string, flag: string): string | null {
462
+ try {
463
+ return readText(path);
464
+ } catch (e) {
465
+ const code = (e as NodeJS.ErrnoException | undefined)?.code;
466
+ console.error(
467
+ code === "ENOENT"
468
+ ? `ultra11y ${cmd}: ${flag} file not found: ${path}.`
469
+ : `ultra11y ${cmd}: cannot read ${flag} ${path}: ${e instanceof Error ? e.message : String(e)}.`,
470
+ );
471
+ return null;
472
+ }
473
+ }
474
+
475
+ /** Resolve `--standard`; prints the error and returns null on an unknown standard. */
476
+ function stdOf(p: ParsedArgs, cmd: string): StandardId | null {
477
+ try {
478
+ return resolveStandard(p.flags.standard);
479
+ } catch (e) {
480
+ console.error(`ultra11y ${cmd}: ${e instanceof Error ? e.message : String(e)}`);
481
+ return null;
482
+ }
483
+ }
484
+
485
+ /** Current ultra11y AuditResult: WCAG-keyed, schema v2+. Rejects stale (pre-v2,
486
+ * RGAA-keyed) JSON so it is never silently mis-processed against the WCAG engine. */
487
+ function isCurrentAudit(r: unknown): r is AuditResult {
488
+ const a = r as Partial<AuditResult> | null;
489
+ return (
490
+ !!a &&
491
+ a.tool === "ultra11y" &&
492
+ a.standard === "wcag" &&
493
+ typeof a.schemaVersion === "number" &&
494
+ a.schemaVersion >= 2 &&
495
+ Array.isArray(a.criteria) &&
496
+ // Require the fields the renderers dereference, so a shallow-fabricated object is
497
+ // rejected cleanly here instead of crashing report/prd with a raw TypeError.
498
+ typeof a.scope === "object" &&
499
+ a.scope !== null &&
500
+ Array.isArray(a.findings) &&
501
+ Array.isArray(a.residualRisks) &&
502
+ Array.isArray(a.guidelines)
503
+ );
504
+ }
505
+
506
+ /** Best-effort load of a `scan --merge <file>` AuditResult, used ONLY to inform
507
+ * `resolveLang` before the dynamic scan runs (so a French repo's `scope.langs`
508
+ * picks the output language same as `report`/`prd` do). Never throws/reports —
509
+ * the actual merge step re-reads and validates the file for real, with the
510
+ * original error messages, so an invalid/missing file still fails there. */
511
+ function peekMergeAudit(mergeIn: string | boolean | undefined): AuditResult | undefined {
512
+ if (typeof mergeIn !== "string" || !mergeIn) return undefined;
513
+ try {
514
+ const parsed: unknown = JSON.parse(readText(mergeIn));
515
+ return isCurrentAudit(parsed) ? parsed : undefined;
516
+ } catch {
517
+ return undefined;
518
+ }
519
+ }
520
+
521
+ async function cmdAudit(p: ParsedArgs): Promise<number> {
522
+ const inputs = p.positionals.length ? p.positionals : ["."];
523
+ if (inputs.length === 0) {
524
+ console.error("ultra11y audit: provide files/globs, or '-' to read stdin.");
525
+ return 2;
526
+ }
527
+ const stdin = inputs.includes("-") ? await readStdin() : undefined;
528
+ const since = typeof p.flags.since === "string" ? (p.flags.since as string) : undefined;
529
+ const dedupFlag = p.flags.dedup;
530
+ // Pre-audit: no scope.langs yet (audit itself doesn't take --standard), so this is
531
+ // just the explicit-flag-or-English fallback — used only by messages emitted before
532
+ // runAudit returns (below). Recomputed with the audit's own detection right after.
533
+ let lang = resolveLang(p.flags, {});
534
+
535
+ // Rendered captures: ingest the .ultra11y/captures dir (or --captures <dir>) alongside
536
+ // the source so the audit covers the REAL DOM component libraries/SFCs emit. In a full
537
+ // scan the whole dir is appended as a top-level input; in --staged/--changed mode a
538
+ // capture is rarely itself part of the diff (the SOURCE changed, not its
539
+ // already-committed capture), so runAudit's `captureDiff` instead pulls in just the
540
+ // captures relevant to the diffed files (capturesForSources). --no-captures opts out
541
+ // of both.
542
+ const requireCaptures = p.flags["require-captures"] === true;
543
+ const capturesFlag = typeof p.flags.captures === "string" && p.flags.captures ? p.flags.captures : undefined;
544
+ const capturesDir = capturesFlag ?? ".ultra11y/captures";
545
+ const scopedToDiff = p.flags.changed === true || p.flags.staged === true || since !== undefined;
546
+ const capturesWanted = p.flags["no-captures"] !== true && !inputs.includes("-") && (capturesFlag !== undefined || existsSync(capturesDir));
547
+ const useCaptures = capturesWanted && !scopedToDiff && !inputs.includes(capturesDir);
548
+ const auditInputs = useCaptures ? [...inputs, capturesDir] : inputs;
549
+ if (useCaptures)
550
+ console.error(
551
+ lang === "fr" ? `ultra11y audit : captures rendues ingérées depuis ${capturesDir}.` : `ultra11y audit: ingesting rendered captures from ${capturesDir}.`,
552
+ );
553
+
554
+ const result = runAudit({
555
+ inputs: auditInputs,
556
+ stdin,
557
+ forceJsx: p.flags.jsx === true,
558
+ include: asList(p.flags.include),
559
+ exclude: asList(p.flags.exclude),
560
+ ext: asList(p.flags.ext),
561
+ changed: p.flags.changed === true || since !== undefined,
562
+ since,
563
+ staged: p.flags.staged === true,
564
+ dedup: dedupFlag === "normalized" || dedupFlag === "off" ? dedupFlag : undefined,
565
+ maxFiles: typeof p.flags["max-files"] === "string" ? Number(p.flags["max-files"]) : undefined,
566
+ graph: p.flags.graph === true || p.flags["cross-file"] === true || requireCaptures,
567
+ captureCoverage: requireCaptures,
568
+ captureDir: capturesDir,
569
+ captureDiff: capturesWanted && scopedToDiff,
570
+ noDefaultExcludes: p.flags["no-default-excludes"] === true,
571
+ onWarn: (m) => console.error(m),
572
+ });
573
+ // Re-resolve with the audit's own repo-language detection (scope.langs) now that
574
+ // it's available — every message from here on uses this, not the pre-audit fallback.
575
+ lang = resolveLang(p.flags, { audit: result });
576
+
577
+ // Only persist audit-latest.json when an output dir is explicitly requested. A plain
578
+ // `audit` streams to stdout (--json / text summary) and must NOT litter the CWD with an
579
+ // audits/ folder. Chain via `audit … --out audits` when you want the file (e.g. for
580
+ // `scan --merge audits/audit-latest.json` or `report --in audits/audit-latest.json`).
581
+ // R6: an `--out` value ending in `.json` is a FILE target (write exactly there);
582
+ // otherwise it's a directory and the canonical `audit-latest.json` lands inside it — so
583
+ // `--out run.json` no longer surprises the user with `run.json/audit-latest.json`.
584
+ if (typeof p.flags.out === "string") {
585
+ const out = p.flags.out;
586
+ const asFile = out.toLowerCase().endsWith(".json");
587
+ const target = asFile ? out : join(out, "audit-latest.json");
588
+ try {
589
+ mkdirSync(asFile ? dirname(out) : out, { recursive: true });
590
+ writeFileSync(target, JSON.stringify(result, null, 2) + "\n");
591
+ // Report the path actually written on STDERR so `--json` stdout stays parseable.
592
+ console.error(lang === "fr" ? `→ audit écrit dans ${target}` : `→ audit written to ${target}`);
593
+ } catch {
594
+ /* non-fatal: still print the result */
595
+ }
596
+ }
597
+
598
+ // Validate --fail-on ONCE (strict): an unrecognized value must error, not silently
599
+ // degrade the gate to blocking-only.
600
+ const failOnRaw = p.flags["fail-on"];
601
+ const failOnParsed = parseFailOn(failOnRaw);
602
+ if (failOnRaw !== undefined && failOnParsed === null) {
603
+ console.error(`ultra11y audit: --fail-on must be blocking|major|minor (got "${String(failOnRaw)}").`);
604
+ return 2;
605
+ }
606
+
607
+ // Regression-gate mode (used by the init hook / CI): diff against a committed
608
+ // baseline and exit non-zero only on NEW non-conformities at/above --fail-on.
609
+ const baselineFlag = p.flags.baseline;
610
+ if (typeof baselineFlag === "string" && baselineFlag) {
611
+ let baseline: AuditResult | null = null;
612
+ if (existsSync(baselineFlag)) {
613
+ try {
614
+ const parsed: unknown = JSON.parse(readText(baselineFlag));
615
+ if (isCurrentAudit(parsed)) baseline = parsed;
616
+ else
617
+ console.error(
618
+ `ultra11y audit: --baseline ${baselineFlag} is stale (pre-v2 / not WCAG-keyed); treating as empty. Regenerate with \`init --baseline\`.`,
619
+ );
620
+ } catch {
621
+ console.error(`ultra11y audit: --baseline ${baselineFlag} is not valid JSON; treating as empty.`);
622
+ }
623
+ }
624
+ const diff = diffAgainstBaseline(result, baseline, failOnParsed ?? "bloquant");
625
+ const blindSpots = requireCaptures ? (result.scope.captureCoverage?.blindSpots ?? []) : [];
626
+ if (p.flags.json)
627
+ console.log(JSON.stringify(requireCaptures && result.scope.captureCoverage ? { ...diff, captureCoverage: result.scope.captureCoverage } : diff, null, 2));
628
+ else {
629
+ console.log(baselineSummary(diff, lang));
630
+ if (requireCaptures && result.scope.captureCoverage) console.error(captureCoverageSummary(result.scope.captureCoverage, lang));
631
+ }
632
+ return diff.ok && blindSpots.length === 0 ? 0 : 1;
633
+ }
634
+
635
+ // Standalone gates (linter-style, no baseline): `--fail-on` gates the whole audit by
636
+ // finding severity; `--require-captures` gates on rendered-capture blind spots. Both
637
+ // compose; a plain audit (neither flag) always exits 0.
638
+ const failOnSet = failOnRaw !== undefined;
639
+ const failOn = failOnSet ? (failOnParsed ?? "bloquant") : undefined;
640
+ const failing = failOn ? findingsAtOrAbove(result.findings, failOn) : [];
641
+ const blindSpots = requireCaptures ? (result.scope.captureCoverage?.blindSpots ?? []) : [];
642
+
643
+ if (p.flags.json) console.log(JSON.stringify(result, null, 2));
644
+ else {
645
+ console.log(auditSummary(result, lang));
646
+ if (requireCaptures && result.scope.captureCoverage) console.error(captureCoverageSummary(result.scope.captureCoverage, lang));
647
+ if (failOnSet && failing.length)
648
+ console.error(lang === "fr" ? `✗ ${failing.length} non-conformité(s) ≥ ${failOn}.` : `✗ ${failing.length} non-conformity(ies) ≥ ${failOn}.`);
649
+ if (requireCaptures && blindSpots.length)
650
+ console.error(
651
+ lang === "fr" ? `✗ ${blindSpots.length} composant(s) sans capture rendue.` : `✗ ${blindSpots.length} component(s) without a rendered capture.`,
652
+ );
653
+ }
654
+ if (!failOnSet && !requireCaptures) return 0;
655
+ return failing.length || blindSpots.length ? 1 : 0;
656
+ }
657
+
658
+ function cmdInit(p: ParsedArgs): number {
659
+ const root = repoRoot() ?? process.cwd();
660
+ let engineRel = process.argv[1] ?? "scripts/ultra11y.mjs";
661
+ try {
662
+ const abs = realpathSync(engineRel);
663
+ engineRel = abs.startsWith(root + sep) ? relative(root, abs) : abs;
664
+ } catch {
665
+ /* keep as-is */
666
+ }
667
+ const failOnParsed = parseFailOn(p.flags["fail-on"]);
668
+ if (p.flags["fail-on"] !== undefined && failOnParsed === null) {
669
+ console.error(`ultra11y init: --fail-on must be blocking|major|minor (got "${String(p.flags["fail-on"])}").`);
670
+ return 2;
671
+ }
672
+ const failOn = failOnParsed ?? "bloquant";
673
+ // The baseline regression gate is opt-in via --baseline or an explicit --fail-on;
674
+ // otherwise init wires the default strict-staged auto-fixing hook (no baseline needed).
675
+ const legacy = p.flags.baseline === true || p.flags["fail-on"] !== undefined;
676
+ const want = { hook: p.flags.hook === true, ci: p.flags.ci === true, baseline: p.flags.baseline === true };
677
+ if (!want.hook && !want.ci && !want.baseline) want.hook = true; // default: the staged auto-fix gate
678
+ if (legacy) want.baseline = true; // the regression gate needs its committed reference
679
+ const wrote: string[] = [];
680
+ if (want.baseline) {
681
+ const inputs = p.positionals.length ? p.positionals : ["."];
682
+ const result = runAudit({ inputs, onWarn: (m) => console.error(m) });
683
+ mkdirSync(join(root, "audits"), { recursive: true });
684
+ const bp = join(root, "audits", "baseline.json");
685
+ writeFileSync(bp, JSON.stringify(result, null, 2) + "\n");
686
+ wrote.push(bp);
687
+ }
688
+ if (want.hook) wrote.push(writeHook(root, engineRel, failOn, legacy ? "baseline" : "staged"));
689
+ if (want.ci) wrote.push(writeCi(root, engineRel, failOn));
690
+ for (const w of wrote) console.log(`ultra11y init: wrote ${w}`);
691
+ if (want.baseline) console.log(`ultra11y init: done. Commit audits/baseline.json so the gate has a reference.`);
692
+ else console.log(`ultra11y init: done. The pre-commit gate audits staged changes and auto-applies safe fixes (bypass once with SKIP_A11Y=1).`);
693
+ return 0;
694
+ }
695
+
696
+ function cmdCriteria(p: ParsedArgs): number {
697
+ // --generate: emit the bundled WCAG references/criteria.md (no trailing newline; the
698
+ // shell redirect / committed file owns that). Used by `pnpm run build:criteria`.
699
+ if (p.flags.generate === true) {
700
+ process.stdout.write(renderCriteriaReference());
701
+ return 0;
702
+ }
703
+ const standard = stdOf(p, "criteria");
704
+ if (standard === null) return 2;
705
+ const themeFlag = p.flags.theme;
706
+ return runCriteria({
707
+ id: p.positionals[0],
708
+ theme: typeof themeFlag === "string" && themeFlag ? Number(themeFlag) : undefined,
709
+ list: p.flags.list === true,
710
+ json: p.flags.json === true,
711
+ lang: resolveLang(p.flags, { standard }),
712
+ standard,
713
+ });
714
+ }
715
+
716
+ async function cmdReport(p: ParsedArgs): Promise<number> {
717
+ const inFlag = p.flags.in;
718
+ if (typeof inFlag !== "string" || !inFlag) {
719
+ console.error("ultra11y report: --in <audit.json> is required ('-' for stdin).");
720
+ return 2;
721
+ }
722
+ const standard = stdOf(p, "report");
723
+ if (standard === null) return 2;
724
+ const raw = inFlag === "-" ? await readStdin() : readInputFile(inFlag, "report", "--in");
725
+ if (raw === null) return 2;
726
+ let result: unknown;
727
+ try {
728
+ result = JSON.parse(raw);
729
+ } catch {
730
+ console.error("ultra11y report: --in is not valid JSON (expected an AuditResult).");
731
+ return 2;
732
+ }
733
+ if (!isCurrentAudit(result)) {
734
+ console.error("ultra11y report: input is not a current ultra11y AuditResult (WCAG-keyed, schema v2). Re-run `audit`.");
735
+ return 2;
736
+ }
737
+ const out = typeof p.flags.out === "string" ? (p.flags.out as string) : "audits";
738
+ const lang = resolveLang(p.flags, { audit: result, standard });
739
+ const path = writeReport(result, { out, lang, standard });
740
+ // Partial-audit advisory (owner decision): a pack (RGAA) report whose scan coverage
741
+ // leaves needs-rendering criteria untested — warn prominently on the CLI, naming exactly
742
+ // which criteria lack a dynamic verdict (the report itself carries the matching banner).
743
+ // Scan stays opt-in but strongly advised.
744
+ const untested = isCore(standard) ? [] : untestedNeedsRendering(result);
745
+ const partial = untested.length > 0;
746
+ if (partial && !p.flags.json) console.error(`🚨 ${partialAuditBanner(lang, untested)}`);
747
+ if (p.flags.json)
748
+ console.log(
749
+ JSON.stringify(
750
+ {
751
+ path,
752
+ conformancePct: result.conformancePct,
753
+ date: result.date,
754
+ standard: typeof p.flags.standard === "string" ? p.flags.standard : "wcag",
755
+ ...(partial ? { partialAudit: true, untestedCriteria: untested } : {}),
756
+ },
757
+ null,
758
+ 2,
759
+ ),
760
+ );
761
+ else console.log(path);
762
+ return 0;
763
+ }
764
+
765
+ async function cmdPrd(p: ParsedArgs): Promise<number> {
766
+ const inFlag = p.flags.in;
767
+ if (typeof inFlag !== "string" || !inFlag) {
768
+ console.error("ultra11y prd: --in <audit.json> is required ('-' for stdin).");
769
+ return 2;
770
+ }
771
+ const standard = stdOf(p, "prd");
772
+ if (standard === null) return 2;
773
+ const raw = inFlag === "-" ? await readStdin() : readInputFile(inFlag, "prd", "--in");
774
+ if (raw === null) return 2;
775
+ let result: unknown;
776
+ try {
777
+ result = JSON.parse(raw);
778
+ } catch {
779
+ console.error("ultra11y prd: --in is not valid JSON (expected an AuditResult).");
780
+ return 2;
781
+ }
782
+ if (!isCurrentAudit(result)) {
783
+ console.error("ultra11y prd: input is not a current ultra11y AuditResult (WCAG-keyed, schema v2). Re-run `audit`.");
784
+ return 2;
785
+ }
786
+ const out = typeof p.flags.out === "string" ? (p.flags.out as string) : "audits";
787
+ const lang = resolveLang(p.flags, { audit: result, standard });
788
+ const split = p.flags.split === "criterion" ? "criterion" : undefined;
789
+ const format: PrdFormat = p.flags.format === "doc" ? "doc" : p.flags.format === "remediation" ? "remediation" : "audit";
790
+ // Technical ticket sections (Partie technique + Contexte de reproduction) are ON by default;
791
+ // `--no-technical` suppresses them for a pure-auditor consumption of the audit block.
792
+ const technical = p.flags["no-technical"] !== true;
793
+ const paths = writePrd(result, { out, lang, split, format, standard, technical });
794
+ const json = p.flags.json === true;
795
+ if (!json) for (const path of paths) console.log(path);
796
+
797
+ // GitHub: always-written markdown above; issues are opt-in + best-effort.
798
+ // --gh-single → one consolidated issue; --gh-issues → one issue per criterion.
799
+ const ghMode: "single" | "per-criterion" | null = p.flags["gh-single"] === true ? "single" : p.flags["gh-issues"] === true ? "per-criterion" : null;
800
+ let gh: { created: number; skipped: number; failed: number; errors: string[] } | undefined;
801
+ if (ghMode) {
802
+ const flag = ghMode === "single" ? "--gh-single" : "--gh-issues";
803
+ const units = prdUnits(result, standard, lang);
804
+ if (!ghAvailable()) {
805
+ if (!json)
806
+ console.error(`ultra11y prd: ${flag} skipped — \`gh\` is not installed or not authenticated (run \`gh auth login\`). Markdown was still written.`);
807
+ } else if (units.length === 0) {
808
+ if (!json) console.error(`ultra11y prd: ${flag} skipped — no findings to file.`);
809
+ } else {
810
+ const issueFormat = format === "remediation" ? "remediation" : "audit";
811
+ gh = ghMode === "single" ? pushSingleIssue(units, lang, standard, issueFormat) : pushIssues(units, lang, standard, issueFormat);
812
+ if (!json)
813
+ console.log(
814
+ lang === "fr"
815
+ ? `ultra11y prd : issues GitHub — ${gh.created} créée(s), ${gh.skipped} déjà existante(s)${gh.failed ? `, ${gh.failed} en échec` : ""}.`
816
+ : `ultra11y prd: GitHub issues — ${gh.created} created, ${gh.skipped} already existed${gh.failed ? `, ${gh.failed} failed` : ""}.`,
817
+ );
818
+ // Surface WHY gh failed (its stderr, previously swallowed) — always, even in --json
819
+ // mode the reasons ride along in the payload; here we print them for humans.
820
+ if (gh.failed && !json) {
821
+ if (gh.errors.length) for (const e of gh.errors) console.error(lang === "fr" ? `ultra11y prd : gh a échoué — ${e}` : `ultra11y prd: gh failed — ${e}`);
822
+ else console.error(lang === "fr" ? `ultra11y prd : gh a échoué sans message d'erreur.` : `ultra11y prd: gh failed with no error output.`);
823
+ }
824
+ }
825
+ }
826
+ if (json) console.log(JSON.stringify({ paths, units: prdUnits(result, standard, lang), ...(gh ? { gh } : {}) }, null, 2));
827
+ // Markdown was written above regardless; but if issue creation was attempted and had
828
+ // any failures, exit non-zero so a CI step / caller sees the GitHub push did not fully
829
+ // succeed (a total failure previously exited 0 and looked green).
830
+ return gh && gh.failed > 0 ? 1 : 0;
831
+ }
832
+
833
+ function cmdRender(p: ParsedArgs): number {
834
+ const root = p.positionals[0] ?? ".";
835
+ const lang = resolveLang(p.flags, {}); // render has no --standard and no audit in hand
836
+ if (p.flags.scaffold === true) {
837
+ const out = typeof p.flags.out === "string" && p.flags.out ? p.flags.out : "ultra11y-render.tsx";
838
+ try {
839
+ writeFileSync(out, ssrHarness());
840
+ } catch (e) {
841
+ console.error(`ultra11y render: could not write ${out}: ${e instanceof Error ? e.message : String(e)}`);
842
+ return 1;
843
+ }
844
+ console.log(
845
+ lang === "fr"
846
+ ? `Harnais SSR écrit : ${out} (INERTE tant que COMPONENTS est vide — l'exécuter tel quel ne produit aucun HTML).\nComplétez COMPONENTS, exécutez-le (ex. npx tsx ${out}), puis : node scripts/ultra11y.mjs audit "audits/rendered/**/*.html"`
847
+ : `SSR harness written: ${out} (INERT while COMPONENTS is empty — running it as-is produces no HTML).\nFill in COMPONENTS, run it (e.g. npx tsx ${out}), then: node scripts/ultra11y.mjs audit "audits/rendered/**/*.html"`,
848
+ );
849
+ return 0;
850
+ }
851
+ if (p.flags.setup === true) {
852
+ const rel = ".ultra11y/capture-setup.mjs";
853
+ const out = join(root, rel);
854
+ try {
855
+ mkdirSync(dirname(out), { recursive: true });
856
+ writeFileSync(out, captureSetup());
857
+ } catch (e) {
858
+ console.error(`ultra11y render: could not write ${out}: ${e instanceof Error ? e.message : String(e)}`);
859
+ return 1;
860
+ }
861
+ let setupDeps: Record<string, string> = {};
862
+ const setupPkg = join(root, "package.json");
863
+ if (existsSync(setupPkg)) {
864
+ try {
865
+ const pkg = JSON.parse(readText(setupPkg)) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
866
+ setupDeps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
867
+ } catch {
868
+ /* detection just sees no deps */
869
+ }
870
+ }
871
+ const tr = detectTestRunner(setupDeps, (f) => existsSync(join(root, f)));
872
+ console.log(captureSetupPlan(tr, rel, lang));
873
+
874
+ // Keep committed captures byte-stable cross-platform + marked generated.
875
+ const gaLine = ".ultra11y/captures/*.html text eol=lf linguist-generated=true";
876
+ const gaPath = join(root, ".gitattributes");
877
+ try {
878
+ const existing = existsSync(gaPath) ? readFileSync(gaPath, "utf8") : "";
879
+ if (!existing.includes(".ultra11y/captures/")) {
880
+ appendFileSync(gaPath, (existing && !existing.endsWith("\n") ? "\n" : "") + gaLine + "\n");
881
+ console.log(lang === "fr" ? `.gitattributes : ajouté « ${gaLine} »` : `.gitattributes: added "${gaLine}"`);
882
+ }
883
+ } catch {
884
+ /* non-fatal */
885
+ }
886
+ // Captures must be committed for the gate — warn if .ultra11y is gitignored.
887
+ try {
888
+ const giPath = join(root, ".gitignore");
889
+ if (existsSync(giPath) && /^\s*\/?\.ultra11y(\/\**)?\/?\s*$/m.test(readFileSync(giPath, "utf8")))
890
+ console.error(
891
+ lang === "fr"
892
+ ? "⚠️ .ultra11y semble ignoré par .gitignore — les captures doivent être committées pour le gate (ajoutez « !.ultra11y/captures/ »)."
893
+ : '⚠️ .ultra11y appears gitignored — captures must be committed for the gate (add "!.ultra11y/captures/").',
894
+ );
895
+ } catch {
896
+ /* non-fatal */
897
+ }
898
+ return 0;
899
+ }
900
+ if (p.flags.storybook === true || typeof p.flags.storybook === "string") {
901
+ const sbDir = p.positionals[0] ?? "storybook-static";
902
+ const indexPath = existsSync(join(sbDir, "index.json")) ? join(sbDir, "index.json") : join(sbDir, "stories.json");
903
+ if (!existsSync(indexPath)) {
904
+ console.error(
905
+ lang === "fr"
906
+ ? `ultra11y render : aucun index Storybook (index.json/stories.json) dans ${sbDir}.`
907
+ : `ultra11y render: no Storybook index (index.json/stories.json) in ${sbDir}.`,
908
+ );
909
+ return 1;
910
+ }
911
+ const stories = parseStorybookIndex(readText(indexPath));
912
+ const provById = new Map(stories.map((s) => [s.id, storyProvenance(s)] as const));
913
+ const capturesFlag = typeof p.flags.captures === "string" && p.flags.captures ? p.flags.captures : undefined;
914
+ const htmlDir = capturesFlag ?? sbDir;
915
+ const htmlFiles = existsSync(htmlDir) ? discover([htmlDir]).files.filter((f) => /\.html?$/i.test(f)) : [];
916
+ const outDir = ".ultra11y/captures";
917
+ let attributed = 0;
918
+ let skipped = 0;
919
+ for (const f of htmlFiles) {
920
+ const raw = readText(f);
921
+ if (parseCaptureProvenance(raw)) {
922
+ skipped++; // already attributed (its own provenance wins)
923
+ continue;
924
+ }
925
+ const base = f.replace(/^.*[/\\]/, "").replace(/\.html?$/i, "");
926
+ // Match the file to a story: exact basename, else the LONGEST story id that is a
927
+ // boundary-suffix of the basename (so one id never matches inside another's).
928
+ let hitId = provById.has(base) ? base : undefined;
929
+ if (!hitId) {
930
+ const cands = stories
931
+ .filter((s) => s.id && base.endsWith(s.id) && (base.length === s.id.length || /[^a-z0-9]/i.test(base[base.length - s.id.length - 1] ?? "")))
932
+ .sort((a, b) => b.id.length - a.id.length);
933
+ hitId = cands[0]?.id;
934
+ }
935
+ const prov = hitId ? provById.get(hitId) : undefined;
936
+ if (!prov?.sourceFile) {
937
+ skipped++;
938
+ continue;
939
+ }
940
+ try {
941
+ mkdirSync(outDir, { recursive: true });
942
+ // Name the output by the unique story id (not the flattened basename) so two
943
+ // story files with the same basename in different dirs never clobber.
944
+ writeFileSync(join(outDir, `${hitId}.html`), `${formatCaptureComment(prov)}\n${raw}${raw.endsWith("\n") ? "" : "\n"}`);
945
+ attributed++;
946
+ } catch {
947
+ skipped++;
948
+ }
949
+ }
950
+ // Honest failure: HTML candidates existed (a bare `storybook build` output, e.g. the
951
+ // iframe/index shell) but NONE could be attributed to a story — a plain static build
952
+ // never emits per-story HTML on its own, so silently exiting 0 here would look like
953
+ // success. 0 candidates at all (nothing under htmlDir) stays exit 0 — there was
954
+ // simply nothing to attribute, a different situation from "tried and failed".
955
+ const remedy =
956
+ lang === "fr"
957
+ ? `Aucun HTML de story attribuable dans ${htmlDir}. Produisez le HTML par story (@storybook/test-runner, ou portable stories + le harvester \`render --setup\`), ou pointez --captures <dir>.`
958
+ : `No attributable per-story HTML in ${htmlDir}. Produce per-story HTML (@storybook/test-runner, or portable stories + the \`render --setup\` harvester), or point --captures <dir>.`;
959
+ const failed = attributed === 0 && htmlFiles.length > 0;
960
+ if (p.flags.json) console.log(JSON.stringify({ attributed, skipped, stories: stories.length, outDir, ...(failed ? { remedy } : {}) }, null, 2));
961
+ else
962
+ console.log(
963
+ lang === "fr"
964
+ ? `Storybook : ${attributed} capture(s) attribuée(s), ${skipped} ignorée(s) → ${outDir} (${stories.length} stories)`
965
+ : `Storybook: ${attributed} capture(s) attributed, ${skipped} skipped → ${outDir} (${stories.length} stories)`,
966
+ );
967
+ if (failed) {
968
+ console.error(remedy);
969
+ return 1;
970
+ }
971
+ return 0;
972
+ }
973
+ if (p.flags.coverage === true) {
974
+ const capturesFlag = typeof p.flags.captures === "string" && p.flags.captures ? p.flags.captures : undefined;
975
+ const capturesDir = capturesFlag ?? join(root, ".ultra11y/captures");
976
+ // Widen to GRAPH_ONLY_EXT (.ts/.js/.mjs/.cjs) so a barrel/plain-JS module resolves
977
+ // cross-file too — same rule as audit --graph (see src/audit.ts).
978
+ const graphExt = [...GRAPH_ONLY_EXT, ...(asList(p.flags.ext) ?? [])];
979
+ const sourceFiles = discover([root], { include: asList(p.flags.include), exclude: asList(p.flags.exclude), ext: graphExt }).files;
980
+ const graph = buildGraphStreaming(sourceFiles);
981
+ const capFiles = existsSync(capturesDir) ? discover([capturesDir]).files : [];
982
+ const entries: CaptureEntry[] = capFiles.map((f) => ({ file: toPosix(f), provenance: parseCaptureProvenance(readText(f)) }));
983
+ const cov = computeCaptureCoverage(graph, entries);
984
+ if (p.flags.json) console.log(JSON.stringify(cov, null, 2));
985
+ else console.log(captureCoverageSummary(cov, lang));
986
+ return 0;
987
+ }
988
+ let deps: Record<string, string> = {};
989
+ const pkgPath = join(root, "package.json");
990
+ if (existsSync(pkgPath)) {
991
+ try {
992
+ const pkg = JSON.parse(readText(pkgPath)) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
993
+ deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
994
+ } catch {
995
+ /* not fatal — detection just sees no deps */
996
+ }
997
+ }
998
+ const detection: Detection = detectFrameworks(deps, (f) => existsSync(join(root, f)));
999
+ if (p.flags.json) console.log(JSON.stringify(detection, null, 2));
1000
+ else console.log(renderPlan(detection, lang));
1001
+ return 0;
1002
+ }
1003
+
1004
+ function cmdCheck(p: ParsedArgs): number {
1005
+ const rep = p.flags.report;
1006
+ if (typeof rep !== "string" || !rep) {
1007
+ console.error("ultra11y check: --report <md> is required.");
1008
+ return 2;
1009
+ }
1010
+ const standard = stdOf(p, "check");
1011
+ if (standard === null) return 2;
1012
+ const lang = resolveLang(p.flags, { standard });
1013
+ const md = readInputFile(rep, "check", "--report");
1014
+ if (md === null) return 2;
1015
+ // --in <audit.json>: enable the pack applicability gate (R1) — re-derive from the audit
1016
+ // and fail on any NC criterion the report over-/under-projects.
1017
+ let audit: AuditResult | undefined;
1018
+ const inFlag = p.flags.in;
1019
+ if (typeof inFlag === "string" && inFlag) {
1020
+ let rawAudit: string;
1021
+ try {
1022
+ rawAudit = readText(inFlag);
1023
+ } catch {
1024
+ console.error(`ultra11y check: --in file not found: ${inFlag}.`);
1025
+ return 2;
1026
+ }
1027
+ try {
1028
+ audit = JSON.parse(rawAudit) as AuditResult;
1029
+ } catch {
1030
+ console.error("ultra11y check: --in file is not valid JSON.");
1031
+ return 2;
1032
+ }
1033
+ }
1034
+ const res = checkReport(md, standard, lang, { audit });
1035
+ // --semantic: the support-level gate ON TOP of the structural check. Fails closed —
1036
+ // a green exit must always mean the gate engaged (family P0: never green-but-inactive).
1037
+ const sem =
1038
+ p.flags.semantic === true
1039
+ ? checkSemantic(md, {
1040
+ reportPath: rep,
1041
+ verdictsPath: typeof p.flags.verdicts === "string" && p.flags.verdicts ? p.flags.verdicts : undefined,
1042
+ standard,
1043
+ lang,
1044
+ })
1045
+ : null;
1046
+ const ok = res.ok && (sem === null || sem.ok);
1047
+ if (p.flags.json) {
1048
+ console.log(JSON.stringify(sem ? { ...res, ok, semantic: sem } : res, null, 2));
1049
+ } else if (!p.flags.quiet) {
1050
+ if (ok)
1051
+ console.log(
1052
+ sem
1053
+ ? lang === "fr"
1054
+ ? `✓ Rapport valide + gate sémantique engagée : ${sem.total} verdict(s), ${sem.grounded} ancré(s) dans la source${sem.moved ? ` (${sem.moved} déplacé(s))` : ""}.`
1055
+ : `✓ Report valid + semantic gate engaged: ${sem.total} verdict(s), ${sem.grounded} grounded in source${sem.moved ? ` (${sem.moved} moved)` : ""}.`
1056
+ : lang === "fr"
1057
+ ? "✓ Rapport valide : sections, critères cités et justifications NA cohérents."
1058
+ : "✓ Report valid: sections, cited criteria and NA justifications are consistent.",
1059
+ );
1060
+ else for (const i of [...res.issues, ...(sem?.issues ?? [])]) console.error(`✗ ${i}`);
1061
+ }
1062
+ return ok ? 0 : 1;
1063
+ }
1064
+
1065
+ function cmdVerify(p: ParsedArgs): number {
1066
+ // --apply has no --standard/audit in hand — resolved below (post-standard) for the --report path.
1067
+ let lang = resolveLang(p.flags, {});
1068
+ const apply = p.flags.apply;
1069
+ if (typeof apply === "string" && apply) {
1070
+ // Read and parse separately so a missing file is not mislabeled as bad JSON.
1071
+ let raw: string;
1072
+ try {
1073
+ raw = readText(apply);
1074
+ } catch {
1075
+ console.error(`ultra11y verify: --apply file not found: ${apply}.`);
1076
+ return 2;
1077
+ }
1078
+ let parsed: unknown;
1079
+ try {
1080
+ parsed = JSON.parse(raw);
1081
+ } catch {
1082
+ console.error("ultra11y verify: --apply file is not valid JSON.");
1083
+ return 2;
1084
+ }
1085
+ // Dispatch on shape: an OBJECT with kind:"adjudication" is a manual-criteria adjudication
1086
+ // (src/adjudicate.ts); a plain ARRAY is the classic NC-verdicts worklist.
1087
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && (parsed as { kind?: string }).kind === "adjudication") {
1088
+ return applyAdjudicationFile(p, parsed as AdjudicationFile, lang);
1089
+ }
1090
+ if (!Array.isArray(parsed)) {
1091
+ console.error("ultra11y verify: --apply must be a JSON array of verdicts, or an adjudication object.");
1092
+ return 2;
1093
+ }
1094
+ const items = parsed as VerifyItem[];
1095
+ // Coverage gate (fail closed): --report is REQUIRED — without the report the gate
1096
+ // cannot know which NCs the verdicts are supposed to cover, and an empty [] would
1097
+ // pass green while covering nothing (family P0: a passing exit must mean the gate
1098
+ // engaged). Coverage is re-derived UNCAPPED so NCs beyond the worklist cap can't
1099
+ // silently escape adjudication.
1100
+ const applyReport = p.flags.report;
1101
+ if (typeof applyReport !== "string" || !applyReport) {
1102
+ console.error(
1103
+ lang === "fr"
1104
+ ? "ultra11y verify : --apply exige --report <md> (le rapport que les verdicts couvrent) — sans lui la couverture ne peut pas être établie."
1105
+ : "ultra11y verify: --apply requires --report <md> (the report the verdicts cover) — without it coverage cannot be established.",
1106
+ );
1107
+ return 2;
1108
+ }
1109
+ const standard = stdOf(p, "verify");
1110
+ if (standard === null) return 2;
1111
+ let repMd: string;
1112
+ try {
1113
+ repMd = readText(applyReport);
1114
+ } catch {
1115
+ console.error(`ultra11y verify: --report file not found: ${applyReport}.`);
1116
+ return 2;
1117
+ }
1118
+ const expected = buildWorklist(repMd, standard, Number.POSITIVE_INFINITY);
1119
+ const r = applyVerdicts(items, expected);
1120
+ // Content-level grounding of every verdict that passed adjudication: the cited
1121
+ // file/line/snippet must still exist and match the source (see src/grounding.ts).
1122
+ const passing = items.filter((it) => typeof it.verdict === "string" && ["supported", "partial"].includes(it.verdict.trim().toLowerCase()));
1123
+ const grounding = groundItems(
1124
+ passing.map((it) => ({ file: it.file, line: it.line, selector: it.selector, snippet: (it as { snippet?: string }).snippet })),
1125
+ );
1126
+ const ok = r.ok && grounding.failed === 0;
1127
+ if (p.flags.json) console.log(JSON.stringify({ ...r, ok, grounding }, null, 2));
1128
+ else if (ok)
1129
+ console.log(
1130
+ lang === "fr"
1131
+ ? `✓ ${r.total} non-conformités vérifiées, toutes étayées et ancrées dans la source${grounding.moved ? ` (${grounding.moved} déplacée(s))` : ""}.`
1132
+ : `✓ ${r.total} non-conformities verified, all supported and grounded in source${grounding.moved ? ` (${grounding.moved} moved)` : ""}.`,
1133
+ );
1134
+ else {
1135
+ if (!r.ok)
1136
+ console.error(
1137
+ lang === "fr"
1138
+ ? `✗ ${r.failures.length}/${r.total} en échec (refuted ${r.refuted}, unsupported ${r.unsupported}, non statué ${r.unadjudicated}${r.missing ? `, absent(s) ${r.missing} — régénérez la worklist avec --max-verify 0` : ""}${r.invalid ? `, invalide ${r.invalid}` : ""}).`
1139
+ : `✗ ${r.failures.length}/${r.total} failed (refuted ${r.refuted}, unsupported ${r.unsupported}, unadjudicated ${r.unadjudicated}${r.missing ? `, missing ${r.missing} — regenerate the worklist with --max-verify 0` : ""}${r.invalid ? `, invalid ${r.invalid}` : ""}).`,
1140
+ );
1141
+ for (const issue of grounding.issues) console.error(`✗ ${issue}`);
1142
+ }
1143
+ return ok ? 0 : 1;
1144
+ }
1145
+
1146
+ const standard = stdOf(p, "verify");
1147
+ if (standard === null) return 2;
1148
+ lang = resolveLang(p.flags, { standard });
1149
+ const out = typeof p.flags.out === "string" ? (p.flags.out as string) : ".";
1150
+
1151
+ // --manual: emit an ADJUDICATION worklist over the audit's residual (manual) criteria —
1152
+ // the judgment/needs-rendering SCs the engine could not decide — pre-loaded with the
1153
+ // evidence the agent rules on. Reads the AUDIT (--in), NOT a report, so --report is
1154
+ // not required here (harvesting re-reads the audited source files).
1155
+ if (p.flags.manual === true) {
1156
+ const inFlag = p.flags.in;
1157
+ if (typeof inFlag !== "string" || !inFlag) {
1158
+ console.error(
1159
+ lang === "fr"
1160
+ ? "ultra11y verify : --manual exige --in <audit.json> (l'audit dont les critères résiduels sont à adjuger)."
1161
+ : "ultra11y verify: --manual requires --in <audit.json> (the audit whose residual criteria are adjudicated).",
1162
+ );
1163
+ return 2;
1164
+ }
1165
+ let audit: AuditResult;
1166
+ try {
1167
+ audit = JSON.parse(readText(inFlag)) as AuditResult;
1168
+ } catch {
1169
+ console.error(`ultra11y verify: --in file not found or not valid JSON: ${inFlag}.`);
1170
+ return 2;
1171
+ }
1172
+ const adjItems = buildAdjudicationWorklist(audit, { standard });
1173
+ const w = writeAdjudication(adjItems, out, { standard, auditDate: audit.date, lang });
1174
+ if (adjItems.every((it) => it.evidence.length === 0)) {
1175
+ console.error(
1176
+ lang === "fr"
1177
+ ? `ultra11y verify : aucune évidence n'a pu être extraite (${audit.scope.inputs.join(", ")} introuvable ?) — lancez --manual depuis le répertoire de l'audit.`
1178
+ : `ultra11y verify: no evidence could be harvested (${audit.scope.inputs.join(", ")} not found?) — run --manual from the audit's directory.`,
1179
+ );
1180
+ }
1181
+ if (p.flags.json) console.log(JSON.stringify({ mdPath: w.mdPath, todoPath: w.todoPath, count: w.count, items: adjItems }, null, 2));
1182
+ else
1183
+ console.log(
1184
+ lang === "fr" ? `${w.count} critère(s) à adjuger → ${w.mdPath}, ${w.todoPath}` : `${w.count} criterion(ia) to adjudicate → ${w.mdPath}, ${w.todoPath}`,
1185
+ );
1186
+ return 0;
1187
+ }
1188
+
1189
+ // Normal NC-verification worklist path — requires --report.
1190
+ const rep = p.flags.report;
1191
+ if (typeof rep !== "string" || !rep) {
1192
+ console.error("ultra11y verify: --report <md> is required (or --apply <verdicts.json>, or --manual --in <audit.json>).");
1193
+ return 2;
1194
+ }
1195
+ let max = VERIFY_MAX;
1196
+ const mvFlag = p.flags["max-verify"];
1197
+ if (typeof mvFlag === "string" && mvFlag !== "") {
1198
+ const n = Number(mvFlag);
1199
+ if (!Number.isInteger(n) || n < 0) {
1200
+ console.error("ultra11y verify: --max-verify must be a non-negative integer.");
1201
+ return 2;
1202
+ }
1203
+ max = n === 0 ? Number.POSITIVE_INFINITY : n; // 0 = no cap
1204
+ }
1205
+ const repMd = readInputFile(rep, "verify", "--report");
1206
+ if (repMd === null) return 2;
1207
+ const items = buildWorklist(repMd, standard, max);
1208
+ const { todoPath, mdPath, count } = writeWorklist(items, out, p.flags.semantic === true, standard, lang);
1209
+ if (p.flags.json) console.log(JSON.stringify({ mdPath, todoPath, count, items }, null, 2));
1210
+ else
1211
+ console.log(
1212
+ lang === "fr" ? `${count} non-conformité(s) à vérifier → ${mdPath}, ${todoPath}` : `${count} non-conformity(ies) to verify → ${mdPath}, ${todoPath}`,
1213
+ );
1214
+ return 0;
1215
+ }
1216
+
1217
+ /** `verify --apply <adjudication.json> --in <audit.json> --out <dir>` — fold an AI
1218
+ * adjudication of the manual criteria back into the audit, fail-closed, then rewrite the
1219
+ * audit JSON so `report`/`prd` re-render with the adjudicated statuses. */
1220
+ function applyAdjudicationFile(p: ParsedArgs, adj: AdjudicationFile, lang: Lang): number {
1221
+ const inFlag = p.flags.in;
1222
+ if (typeof inFlag !== "string" || !inFlag) {
1223
+ console.error(
1224
+ lang === "fr"
1225
+ ? "ultra11y verify : --apply <adjudication> exige --in <audit.json> (l'audit à mettre à jour)."
1226
+ : "ultra11y verify: --apply <adjudication> requires --in <audit.json> (the audit to update).",
1227
+ );
1228
+ return 2;
1229
+ }
1230
+ let audit: AuditResult;
1231
+ try {
1232
+ audit = JSON.parse(readText(inFlag)) as AuditResult;
1233
+ } catch {
1234
+ console.error(`ultra11y verify: --in file not found or not valid JSON: ${inFlag}.`);
1235
+ return 2;
1236
+ }
1237
+ const r = applyAdjudication(audit, adj);
1238
+ if (!r.ok) {
1239
+ if (p.flags.json) console.log(JSON.stringify(r, null, 2));
1240
+ else {
1241
+ console.error(lang === "fr" ? `✗ Adjudication rejetée (${r.issues.length} problème(s)) :` : `✗ Adjudication rejected (${r.issues.length} issue(s)):`);
1242
+ for (const i of r.issues) console.error(` ✗ ${i}`);
1243
+ }
1244
+ return 1;
1245
+ }
1246
+ // Persist the updated audit so report/prd re-render with the adjudicated statuses.
1247
+ const out = typeof p.flags.out === "string" ? (p.flags.out as string) : ".";
1248
+ mkdirSync(out, { recursive: true });
1249
+ const auditPath = join(out, "audit-latest.json");
1250
+ writeFileSync(auditPath, JSON.stringify(r.audit, null, 2) + "\n");
1251
+ if (p.flags.json) console.log(JSON.stringify({ ok: true, auditPath, applied: r.applied, stillManual: r.stillManual, grounding: r.grounding }, null, 2));
1252
+ else
1253
+ console.log(
1254
+ lang === "fr"
1255
+ ? `✓ ${r.applied} critère(s) adjugé(s), ${r.stillManual} laissé(s) en résiduel → ${auditPath}`
1256
+ : `✓ ${r.applied} criterion(ia) adjudicated, ${r.stillManual} left residual → ${auditPath}`,
1257
+ );
1258
+ return 0;
1259
+ }
1260
+
1261
+ async function cmdFix(p: ParsedArgs): Promise<number> {
1262
+ const inputs = p.positionals.length ? p.positionals : ["."];
1263
+ const stdin = inputs.includes("-") ? await readStdin() : undefined;
1264
+ const since = typeof p.flags.since === "string" ? (p.flags.since as string) : undefined;
1265
+ const write = p.flags.write === true;
1266
+ const onlyFlag = p.flags.only;
1267
+ if (onlyFlag === "" || (typeof onlyFlag === "string" && !onlyFlag.trim())) {
1268
+ console.error("ultra11y fix: --only requires one or more rule ids (comma-separated).");
1269
+ return 2;
1270
+ }
1271
+ const opts = {
1272
+ inputs,
1273
+ stdin,
1274
+ forceJsx: p.flags.jsx === true,
1275
+ include: asList(p.flags.include),
1276
+ exclude: asList(p.flags.exclude),
1277
+ ext: asList(p.flags.ext),
1278
+ changed: p.flags.changed === true || since !== undefined,
1279
+ since,
1280
+ staged: p.flags.staged === true,
1281
+ safe: p.flags.safe === true,
1282
+ noDefaultExcludes: p.flags["no-default-excludes"] === true,
1283
+ only:
1284
+ typeof onlyFlag === "string"
1285
+ ? onlyFlag
1286
+ .split(",")
1287
+ .map((s) => s.trim())
1288
+ .filter(Boolean)
1289
+ : undefined,
1290
+ write,
1291
+ onWarn: (m: string) => console.error(m),
1292
+ };
1293
+
1294
+ // --iterate: re-audit and re-apply the mechanical fixes until a round writes
1295
+ // nothing new (bounded). Each round re-reads files, so it converges quickly —
1296
+ // a codemod removes the finding it fixed, so it is not re-applied. Round 1 holds
1297
+ // the meaningful diff; later rounds just confirm stability (or catch cascades).
1298
+ const result = runFix(opts);
1299
+ let rounds = 1;
1300
+ let totalWritten = result.totals.filesWritten;
1301
+ if (write && p.flags.iterate === true) {
1302
+ const MAX_ROUNDS = 5;
1303
+ let last = result;
1304
+ while (last.totals.filesWritten > 0 && rounds < MAX_ROUNDS) {
1305
+ last = runFix(opts);
1306
+ rounds++;
1307
+ totalWritten += last.totals.filesWritten;
1308
+ }
1309
+ }
1310
+
1311
+ // `fix` runs its own rule pass (no AuditResult/scope.langs is built internally — see
1312
+ // src/fix.ts), so there is no repo-language signal to feed resolveLang beyond the flag.
1313
+ const fixLang = resolveLang(p.flags, {});
1314
+ if (p.flags.json) console.log(JSON.stringify(p.flags.iterate === true ? { ...result, rounds, totalWritten } : result, null, 2));
1315
+ else {
1316
+ console.log(fixSummary(result, fixLang, write));
1317
+ if (write && p.flags.iterate === true)
1318
+ console.log(
1319
+ fixLang === "fr"
1320
+ ? `\nItéré sur ${rounds} passe(s) jusqu'à stabilité — ${totalWritten} fichier(s) écrit(s) au total.`
1321
+ : `\nIterated over ${rounds} round(s) to a fixpoint — ${totalWritten} file(s) written in total.`,
1322
+ );
1323
+ }
1324
+ return 0;
1325
+ }
1326
+
1327
+ async function cmdScan(p: ParsedArgs): Promise<number> {
1328
+ // scan has no --standard. With --merge, peek the target audit's scope.langs BEFORE
1329
+ // resolving lang (a French repo audited without --lang must not get English dyn-*
1330
+ // text permanently baked with no way back — see peekMergeAudit). --lang stays explicit-wins.
1331
+ const mergeAudit = peekMergeAudit(p.flags.merge);
1332
+ const lang = resolveLang(p.flags, mergeAudit ? { audit: mergeAudit } : {});
1333
+ if (p.flags.clean) {
1334
+ const r = cleanDynamic();
1335
+ console.log(
1336
+ lang === "fr"
1337
+ ? `Nettoyage : image dynamique ${r.imageRemoved ? "supprimée" : "absente"}, ${r.tempContextsRemoved} contexte(s) temporaire(s) supprimé(s).`
1338
+ : `Cleanup: dynamic image ${r.imageRemoved ? "removed" : "absent"}, ${r.tempContextsRemoved} temp context(s) removed.`,
1339
+ );
1340
+ return 0;
1341
+ }
1342
+ // A local target that does not exist is a user error, independent of any runtime — say
1343
+ // so BEFORE probing for Playwright/Docker, otherwise a machine with neither reports
1344
+ // "no runtime available" for what is really a typo in the path.
1345
+ for (const target of p.positionals.filter((a) => a !== "-")) {
1346
+ if (/^https?:\/\//i.test(target) || existsSync(target)) continue;
1347
+ console.error(
1348
+ lang === "fr"
1349
+ ? `ultra11y scan : fichier introuvable (file not found) : ${target}. Passez une URL http(s):// ou un fichier HTML existant.`
1350
+ : `ultra11y scan: File not found: ${target}. Pass an http(s):// URL or an existing HTML file.`,
1351
+ );
1352
+ return 1;
1353
+ }
1354
+ // Resolve the execution runtime. `auto` (default) prefers the local host/target
1355
+ // Playwright (no Docker) when it resolves from --cwd, else falls back to Docker.
1356
+ const cwd = typeof p.flags.cwd === "string" && p.flags.cwd ? (p.flags.cwd as string) : process.cwd();
1357
+ const storageState = typeof p.flags["storage-state"] === "string" && p.flags["storage-state"] ? (p.flags["storage-state"] as string) : undefined;
1358
+ const runtimeFlag =
1359
+ typeof p.flags.runtime === "string" && p.flags.runtime
1360
+ ? (p.flags.runtime as string)
1361
+ : p.flags.local === true
1362
+ ? "local"
1363
+ : p.flags.docker === true
1364
+ ? "docker"
1365
+ : "auto";
1366
+ if (!["auto", "local", "docker"].includes(runtimeFlag)) {
1367
+ console.error(`ultra11y scan: --runtime must be local, docker, or auto (got "${runtimeFlag}").`);
1368
+ return 2;
1369
+ }
1370
+ let useLocal: boolean;
1371
+ if (runtimeFlag === "local") useLocal = true;
1372
+ else if (runtimeFlag === "docker") useLocal = false;
1373
+ else if (localAvailable(cwd)) useLocal = true;
1374
+ else if (dockerAvailable()) useLocal = false;
1375
+ else {
1376
+ console.error(
1377
+ lang === "fr"
1378
+ ? "ultra11y scan : aucun runtime disponible — ni Playwright local (passez --cwd vers un projet avec @playwright/test + @axe-core/playwright installés), ni Docker. Voir --runtime."
1379
+ : "ultra11y scan: no runtime available — neither a local Playwright (pass --cwd at a project with @playwright/test + @axe-core/playwright installed) nor Docker. See --runtime.",
1380
+ );
1381
+ return 1;
1382
+ }
1383
+ if (storageState && !useLocal) {
1384
+ // --storage-state + the Docker tier is an unsupported combination, not a
1385
+ // degrade-and-continue: the Docker runner has no mechanism to use a Playwright
1386
+ // storageState, so scanning unauthenticated would produce MISLEADING results (a login
1387
+ // wall instead of the app) while exiting 0. This holds whether Docker was asked for
1388
+ // EXPLICITLY (--runtime docker/--docker) or reached as the auto fallback (no local
1389
+ // Playwright resolved) — in both cases authenticated scanning needs the local runtime.
1390
+ console.error(
1391
+ runtimeFlag === "docker"
1392
+ ? lang === "fr"
1393
+ ? "ultra11y scan : --storage-state n'est pas pris en charge avec --runtime docker (ou --docker) — combinaison non supportée. Utilisez --runtime local avec --cwd."
1394
+ : "ultra11y scan: --storage-state is not supported with --runtime docker (or --docker) — unsupported combination. Use --runtime local with --cwd."
1395
+ : lang === "fr"
1396
+ ? "ultra11y scan : --storage-state exige le runtime local, mais aucun Playwright local n'a été résolu (auto a basculé sur Docker). Passez --runtime local --cwd <projet>, ou retirez --storage-state."
1397
+ : "ultra11y scan: --storage-state requires the local runtime, but no local Playwright was resolved (auto fell back to Docker). Pass --runtime local --cwd <project>, or drop --storage-state.",
1398
+ );
1399
+ return 2;
1400
+ }
1401
+
1402
+ // Stateful probes (fill inputs → input-overflow, open dialogs, live-region) are ON by
1403
+ // default for the local runtime. SAFETY CONTRACT: they perform only non-navigating
1404
+ // actions (fill text inputs, toggle checkbox/radio, click button[type=button]) — never a
1405
+ // link, never a submit, never a form submit, never a button whose accessible name matches
1406
+ // a destructive verb (fr/en) — and abort+restore if location.href changes. On an
1407
+ // AUTHENTICATED scan (--storage-state) the button clicks are additionally skipped by
1408
+ // default (a click can trigger a server mutation invisible to the href assertion);
1409
+ // `--interact-clicks` opts back in. `--no-interact` disables all stateful probes, leaving
1410
+ // only the pristine-page ones. (Docker never interacts.)
1411
+ const interact = p.flags["no-interact"] !== true;
1412
+ const interactClicks = p.flags["interact-clicks"] === true;
1413
+ const sitemap = typeof p.flags.sitemap === "string" ? (p.flags.sitemap as string) : undefined;
1414
+ const crawl = typeof p.flags.crawl === "string" ? (p.flags.crawl as string) : undefined;
1415
+
1416
+ // --sample: iterate the NORMATIVE page sample from `.ultra11yrc.json` (per-page
1417
+ // storageState overrides --storage-state). Loaded + validated here (hard error on a
1418
+ // malformed sample). An authenticated page needs the local runtime — the Docker tier has
1419
+ // no storageState mechanism, so a sample with any auth page + Docker is refused, not run
1420
+ // unauthenticated. SECURITY: storageState is only ever a path — its content is never read.
1421
+ const useSample = p.flags.sample === true;
1422
+ let sampleConfig: SampleConfig | undefined;
1423
+ if (useSample) {
1424
+ let cfg: ReturnType<typeof loadConfig>;
1425
+ try {
1426
+ cfg = loadConfig(process.cwd());
1427
+ } catch (e) {
1428
+ console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
1429
+ return 2;
1430
+ }
1431
+ if (!cfg?.sample) {
1432
+ console.error(
1433
+ lang === "fr"
1434
+ ? "ultra11y scan : --sample exige un bloc `sample` dans .ultra11yrc.json (pages de l'échantillon). Voir `ultra11y sample check`."
1435
+ : "ultra11y scan: --sample requires a `sample` block in .ultra11yrc.json (the sample pages). See `ultra11y sample check`.",
1436
+ );
1437
+ return 2;
1438
+ }
1439
+ const v = validateSample(cfg.sample);
1440
+ for (const w of v.warnings) console.error(`⚠ ${w.path ? `${w.path}: ` : ""}${w.message}`);
1441
+ if (!v.ok || !v.sample) {
1442
+ console.error(lang === "fr" ? "ultra11y scan : bloc `sample` invalide :" : "ultra11y scan: invalid `sample` block:");
1443
+ for (const i of v.issues) console.error(` ✗ ${i.path ? `${i.path}: ` : ""}${i.message}`);
1444
+ return 2;
1445
+ }
1446
+ sampleConfig = v.sample;
1447
+ if (!useLocal && sampleConfig.pages.some((pg) => pg.storageState)) {
1448
+ console.error(
1449
+ lang === "fr"
1450
+ ? "ultra11y scan : l'échantillon comporte des pages authentifiées (storageState), non prises en charge par le runtime Docker. Utilisez --runtime local --cwd <projet>."
1451
+ : "ultra11y scan: the sample has authenticated pages (storageState), unsupported by the Docker runtime. Use --runtime local --cwd <project>.",
1452
+ );
1453
+ return 2;
1454
+ }
1455
+ }
1456
+
1457
+ let dynamic: DynamicResult;
1458
+ try {
1459
+ if (useSample && sampleConfig) {
1460
+ dynamic = useLocal
1461
+ ? await runSampleScanLocal(sampleConfig.pages, { cwd, storageState, lang, interact, interactClicks })
1462
+ : runSampleScan(sampleConfig.pages);
1463
+ } else if (sitemap || crawl) {
1464
+ const depth = typeof p.flags.depth === "string" ? Number(p.flags.depth) : undefined;
1465
+ const max = typeof p.flags.max === "string" ? Number(p.flags.max) : undefined;
1466
+ dynamic = useLocal
1467
+ ? await runCrawlScanLocal({ sitemap, crawl, depth, max, cwd, storageState, lang, interact, interactClicks })
1468
+ : await runCrawlScan({ sitemap, crawl, depth, max });
1469
+ } else {
1470
+ const targets = p.positionals.filter((a) => a !== "-");
1471
+ if (targets.length === 0) {
1472
+ console.error("ultra11y scan: provide one or more URLs/HTML files, --sitemap <url>, --crawl <url>, or --clean.");
1473
+ return 2;
1474
+ }
1475
+ if (useLocal) {
1476
+ dynamic =
1477
+ targets.length === 1
1478
+ ? await runScanLocal({ target: targets[0]!, cwd, storageState, lang, interact, interactClicks })
1479
+ : await runScanManyLocal(targets, { cwd, storageState, lang, interact, interactClicks });
1480
+ } else {
1481
+ dynamic = targets.length === 1 ? runScan({ target: targets[0]! }) : runScanMany(targets);
1482
+ }
1483
+ }
1484
+ } catch (e) {
1485
+ console.error(`ultra11y scan: ${e instanceof Error ? e.message : String(e)}`);
1486
+ return 1;
1487
+ }
1488
+ // Advisory sample-methodology lint: which normative page KINDS the configured sample
1489
+ // lacks. Standard-agnostic mechanics + RGAA-specific required kinds; scan has no
1490
+ // --standard, so we lint against the config's default standard (else RGAA if registered).
1491
+ // Never a gate — a warning only.
1492
+ if (useSample && sampleConfig) {
1493
+ const lintKey = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
1494
+ try {
1495
+ const pack = getPack(resolveStandard(lintKey));
1496
+ const methodology = pack?.sampleMethodology;
1497
+ if (methodology) {
1498
+ const { missing } = lintSample(sampleConfig, methodology);
1499
+ if (missing.length)
1500
+ console.error(
1501
+ (lang === "fr"
1502
+ ? `⚠️ Échantillon incomplet — types de page requis absents (${pack.name}) : `
1503
+ : `⚠️ Incomplete sample — required page kinds missing (${pack.name}): `) + missing.map((k) => kindLabel(k, pack.defaultLocale)).join(", "),
1504
+ );
1505
+ }
1506
+ } catch {
1507
+ /* unknown standard for lint — skip advisory */
1508
+ }
1509
+ }
1510
+ const out = typeof p.flags.out === "string" ? (p.flags.out as string) : "audits";
1511
+ const mergeIn = p.flags.merge;
1512
+ if (typeof mergeIn === "string" && mergeIn) {
1513
+ let audit: AuditResult;
1514
+ if (mergeAudit) {
1515
+ audit = mergeAudit; // already loaded + validated above for lang resolution
1516
+ } else {
1517
+ let parsed: unknown;
1518
+ try {
1519
+ parsed = JSON.parse(readText(mergeIn));
1520
+ } catch {
1521
+ console.error("ultra11y scan: --merge is not valid JSON (expected an AuditResult).");
1522
+ return 2;
1523
+ }
1524
+ if (!isCurrentAudit(parsed)) {
1525
+ console.error("ultra11y scan: --merge input is not a current ultra11y AuditResult (WCAG-keyed, schema v2). Re-run `audit`.");
1526
+ return 2;
1527
+ }
1528
+ audit = parsed;
1529
+ }
1530
+ const merged = mergeDynamic(audit, dynamic, lang);
1531
+ mkdirSync(out, { recursive: true });
1532
+ writeFileSync(join(out, "audit-latest.json"), JSON.stringify(merged, null, 2) + "\n");
1533
+ if (p.flags.json) console.log(JSON.stringify(merged, null, 2));
1534
+ else
1535
+ console.log(
1536
+ lang === "fr"
1537
+ ? `Audit statique + dynamique fusionné → ${join(out, "audit-latest.json")} (${merged.conformancePct}% réussite, ${merged.findings.length} findings).`
1538
+ : `Static + dynamic audit merged → ${join(out, "audit-latest.json")} (${merged.conformancePct}% pass rate, ${merged.findings.length} findings).`,
1539
+ );
1540
+ return 0;
1541
+ }
1542
+ if (p.flags.json) console.log(JSON.stringify(dynamic, null, 2));
1543
+ else {
1544
+ console.log(
1545
+ lang === "fr"
1546
+ ? `Audit dynamique (${dynamic.engine}) de ${dynamic.target} — ${dynamic.findings.length} non-conformité(s) :`
1547
+ : `Dynamic audit (${dynamic.engine}) of ${dynamic.target} — ${dynamic.findings.length} non-conformity(ies):`,
1548
+ );
1549
+ for (const f of dynamic.findings.slice(0, 30)) console.log(` [${f.criteriaId}] ${f.selector} — ${f.message}`);
1550
+ }
1551
+ return 0;
1552
+ }
1553
+
1554
+ /** `sample check` — advisory lint of the configured page sample (`.ultra11yrc.json`
1555
+ * `sample` block) against a standard's normative methodology: which REQUIRED page kinds it
1556
+ * lacks. A malformed sample is a hard error (exit 2); a merely-incomplete one is advisory
1557
+ * (exit 0) — the sample is opt-in and the missing kinds are guidance, not a gate. */
1558
+ function cmdSample(p: ParsedArgs): number {
1559
+ const action = p.positionals[0];
1560
+ // Default the lint standard to the config's default (copied to --standard in main) else
1561
+ // RGAA (the standard that ships a sampleMethodology). WCAG core carries none.
1562
+ const key = typeof p.flags.standard === "string" && p.flags.standard ? p.flags.standard : "rgaa";
1563
+ let standard: StandardId;
1564
+ try {
1565
+ standard = resolveStandard(key);
1566
+ } catch (e) {
1567
+ console.error(`ultra11y sample: ${e instanceof Error ? e.message : String(e)}`);
1568
+ return 2;
1569
+ }
1570
+ const lang = resolveLang(p.flags, { standard });
1571
+ if (action !== "check") {
1572
+ console.error("ultra11y sample: expected `sample check [--standard <pack>]`.");
1573
+ return 2;
1574
+ }
1575
+ let cfg: ReturnType<typeof loadConfig>;
1576
+ try {
1577
+ cfg = loadConfig(process.cwd());
1578
+ } catch (e) {
1579
+ console.error(`ultra11y sample: ${e instanceof Error ? e.message : String(e)}`);
1580
+ return 2;
1581
+ }
1582
+ if (!cfg?.sample) {
1583
+ console.error(
1584
+ lang === "fr"
1585
+ ? "ultra11y sample : aucun bloc `sample` dans .ultra11yrc.json — ajoutez `sample.pages` (échantillon de pages représentatives)."
1586
+ : "ultra11y sample: no `sample` block in .ultra11yrc.json — add `sample.pages` (the representative page sample).",
1587
+ );
1588
+ return 2;
1589
+ }
1590
+ const v = validateSample(cfg.sample);
1591
+ if (!p.flags.json) for (const w of v.warnings) console.error(`⚠ ${w.path ? `${w.path}: ` : ""}${w.message}`);
1592
+ if (!v.ok || !v.sample) {
1593
+ if (p.flags.json) console.log(JSON.stringify({ ok: false, issues: v.issues, warnings: v.warnings }, null, 2));
1594
+ else {
1595
+ console.error(lang === "fr" ? "✗ Bloc `sample` invalide :" : "✗ Invalid `sample` block:");
1596
+ for (const i of v.issues) console.error(` ✗ ${i.path ? `${i.path}: ` : ""}${i.message}`);
1597
+ }
1598
+ return 2;
1599
+ }
1600
+ const pack = isCore(standard) ? null : getPack(standard);
1601
+ const methodology = pack?.sampleMethodology;
1602
+ if (!methodology) {
1603
+ if (p.flags.json)
1604
+ console.log(
1605
+ JSON.stringify({ ok: true, pages: v.sample.pages.length, missing: [], warnings: v.warnings, note: "no sample methodology for this standard" }, null, 2),
1606
+ );
1607
+ else
1608
+ console.log(
1609
+ lang === "fr"
1610
+ ? `✓ Échantillon valide (${v.sample.pages.length} page(s)). Le référentiel actif (${standardLabelSafe(standard)}) ne définit pas de méthodologie d'échantillon — rien à vérifier.`
1611
+ : `✓ Sample valid (${v.sample.pages.length} page(s)). The active standard (${standardLabelSafe(standard)}) defines no sample methodology — nothing to check.`,
1612
+ );
1613
+ return 0;
1614
+ }
1615
+ const { missing } = lintSample(v.sample, methodology);
1616
+ const loc = pack?.defaultLocale ?? "fr";
1617
+ if (p.flags.json) {
1618
+ console.log(
1619
+ JSON.stringify(
1620
+ { ok: true, pages: v.sample.pages.length, missing: missing.map((k) => ({ id: k.id, label: kindLabel(k, loc) })), warnings: v.warnings },
1621
+ null,
1622
+ 2,
1623
+ ),
1624
+ );
1625
+ return 0;
1626
+ }
1627
+ if (missing.length === 0) {
1628
+ console.log(
1629
+ lang === "fr"
1630
+ ? `✓ Échantillon complet (${v.sample.pages.length} page(s)) — tous les types de page requis par ${pack!.name} sont couverts.`
1631
+ : `✓ Sample complete (${v.sample.pages.length} page(s)) — every page kind ${pack!.name} requires is covered.`,
1632
+ );
1633
+ } else {
1634
+ console.log(
1635
+ (lang === "fr"
1636
+ ? `⚠️ Échantillon incomplet (${v.sample.pages.length} page(s)) — types de page requis absents (${pack!.name}) :`
1637
+ : `⚠️ Incomplete sample (${v.sample.pages.length} page(s)) — required page kinds missing (${pack!.name}):`) +
1638
+ ` ${missing.map((k) => kindLabel(k, loc)).join(", ")}`,
1639
+ );
1640
+ }
1641
+ return 0;
1642
+ }
1643
+
1644
+ /** standardLabel that never throws for the core (used in an advisory message). */
1645
+ function standardLabelSafe(standard: StandardId): string {
1646
+ return isCore(standard) ? "WCAG 2.2 AA" : (getPack(standard)?.name ?? standard);
1647
+ }
1648
+
1649
+ function cmdPack(p: ParsedArgs): number {
1650
+ const action = p.positionals[0];
1651
+ const lang = resolveLang(p.flags, {}); // pack has no --standard (it validates a pack, not runs against one)
1652
+ if (action === "scaffold") {
1653
+ console.log(packScaffold());
1654
+ return 0;
1655
+ }
1656
+ if (action === "check") {
1657
+ const packPath = p.positionals[1];
1658
+ if (!packPath) {
1659
+ console.error("ultra11y pack check: provide a pack JSON file — `pack check <pack.json> [--guidance <g.json>]`.");
1660
+ return 2;
1661
+ }
1662
+ const guidance = typeof p.flags.guidance === "string" && p.flags.guidance ? (p.flags.guidance as string) : undefined;
1663
+ const res = runPackCheck(packPath, guidance);
1664
+ if (p.flags.json) {
1665
+ console.log(JSON.stringify(res, null, 2));
1666
+ } else {
1667
+ for (const w of res.warnings) console.error(`⚠ ${w}`);
1668
+ if (res.ok) console.log(lang === "fr" ? `✓ Pack valide${guidance ? " (+ guidance)" : ""}.` : `✓ Pack valid${guidance ? " (+ guidance)" : ""}.`);
1669
+ else for (const e of res.errors) console.error(`✗ ${e}`);
1670
+ }
1671
+ return res.ok ? 0 : 1;
1672
+ }
1673
+ console.error("ultra11y pack: expected `pack check <pack.json> [--guidance <g.json>]` or `pack scaffold`.");
1674
+ return 2;
1675
+ }
1676
+
1677
+ // Serve the audit over the Model Context Protocol. Returns only when the server
1678
+ // stops, so main() does not fall through while it is still listening.
1679
+ async function cmdMcp(p: ParsedArgs): Promise<number> {
1680
+ const transport = typeof p.flags.transport === "string" ? p.flags.transport : "stdio";
1681
+ if (transport !== "stdio" && transport !== "http") {
1682
+ console.error(`ultra11y: invalid --transport "${transport}" (expected: stdio, http)`);
1683
+ return 2;
1684
+ }
1685
+ const maxRaw = typeof p.flags["max-response-bytes"] === "string" ? Number(p.flags["max-response-bytes"]) : undefined;
1686
+ if (maxRaw !== undefined && (!Number.isFinite(maxRaw) || maxRaw <= 0)) {
1687
+ console.error("ultra11y: invalid --max-response-bytes");
1688
+ return 2;
1689
+ }
1690
+ const options = {
1691
+ // A default project root makes `cwd` optional on every tool, for a server
1692
+ // dedicated to one project.
1693
+ defaultCwd: typeof p.flags.cwd === "string" ? p.flags.cwd : undefined,
1694
+ allowWrite: p.flags["allow-write"] === true,
1695
+ maxResponseBytes: maxRaw,
1696
+ };
1697
+
1698
+ if (transport === "stdio") {
1699
+ // Nothing is written to stdout here: from this point stdout carries
1700
+ // JSON-RPC frames only, and runStdioServer guards that.
1701
+ await runStdioServer(options);
1702
+ return 0;
1703
+ }
1704
+
1705
+ const port = typeof p.flags.port === "string" ? Number(p.flags.port) : 7341;
1706
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
1707
+ console.error("ultra11y: invalid --port");
1708
+ return 2;
1709
+ }
1710
+ const originRaw = typeof p.flags["allow-origin"] === "string" ? p.flags["allow-origin"] : undefined;
1711
+ const allowOrigin = originRaw
1712
+ ? originRaw
1713
+ .split(",")
1714
+ .map((x) => x.trim())
1715
+ .filter(Boolean)
1716
+ : undefined;
1717
+ let running: Awaited<ReturnType<typeof startHttpServer>>;
1718
+ try {
1719
+ running = await startHttpServer({
1720
+ ...options,
1721
+ port,
1722
+ bind: typeof p.flags.bind === "string" ? p.flags.bind : undefined,
1723
+ allowOrigin,
1724
+ allowRemote: p.flags["allow-remote"] === true,
1725
+ });
1726
+ } catch (e) {
1727
+ console.error(`ultra11y: ${(e as Error).message}`);
1728
+ return 2;
1729
+ }
1730
+ // stderr, not stdout: an HTTP server's stdout is not a protocol stream, but
1731
+ // keeping the two transports identical here means no one has to remember
1732
+ // which is which.
1733
+ console.error(`ultra11y: MCP server listening on ${running.url}`);
1734
+ console.error(` client: claude mcp add --transport http ultra11y ${running.url}`);
1735
+ for (const sig of ["SIGINT", "SIGTERM"] as const) {
1736
+ process.once(sig, () => {
1737
+ void running.close().then(() => process.exit(0));
1738
+ });
1739
+ }
1740
+ await new Promise<void>((res) => running.server.once("close", res));
1741
+ return 0;
1742
+ }
1743
+
1744
+ function cmdOrchestrate(p: ParsedArgs): number {
1745
+ const lang = resolveLang(p.flags, {});
1746
+ const runFlag = p.flags.run;
1747
+ if (typeof runFlag !== "string" || !runFlag) {
1748
+ console.error(
1749
+ lang === "fr"
1750
+ ? "ultra11y orchestrate : --run <dir> est requis (le dossier du run contenant les worklists)."
1751
+ : "ultra11y orchestrate: --run <dir> is required (the run dir holding the worklists).",
1752
+ );
1753
+ return 2;
1754
+ }
1755
+ const engineAbs = realpathSync(fileURLToPath(import.meta.url));
1756
+ if (p.flags.list === true) {
1757
+ if (!existsSync(runFlag)) {
1758
+ console.error(`ultra11y orchestrate: run dir not found: ${runFlag}.`);
1759
+ return 2;
1760
+ }
1761
+ console.log(JSON.stringify({ phases: listPhases(runFlag, engineAbs) }, null, 2));
1762
+ return 0;
1763
+ }
1764
+ const res = orchestrateRun(runFlag, engineAbs, {
1765
+ phase: typeof p.flags.phase === "string" && p.flags.phase ? p.flags.phase : undefined,
1766
+ eco: p.flags.eco === true,
1767
+ });
1768
+ if (res.exitCode !== 0) {
1769
+ for (const e of res.errors) console.error(`ultra11y orchestrate: ${e}`);
1770
+ return res.exitCode;
1771
+ }
1772
+ console.log(lang === "fr" ? "ultra11y orchestrate : généré" : "ultra11y orchestrate: generated");
1773
+ for (const w of res.written) console.log(` ${w}`);
1774
+ for (const n of res.notices) console.error(`ultra11y orchestrate: note — ${n}`);
1775
+ const workflows = res.written.filter((w) => w.endsWith(".workflow.mjs"));
1776
+ if (workflows.length) {
1777
+ console.log("");
1778
+ for (const w of workflows) console.log(`Launch: Workflow({ scriptPath: ${JSON.stringify(w)} })`);
1779
+ console.log(
1780
+ lang === "fr"
1781
+ ? "Puis fusionnez les fragments retournés dans la worklist et lancez le `verify --apply` indiqué en fin de workflow (vous restez le seul écrivain)."
1782
+ : "Then fold the returned fragments into the worklist and run the `verify --apply` shown at the end of each workflow (you stay the sole writer).",
1783
+ );
1784
+ } else {
1785
+ console.log(
1786
+ lang === "fr"
1787
+ ? `Suivez ${join(runFlag, "orchestration", "RUNBOOK.md")} séquentiellement (chemin éco).`
1788
+ : `Follow ${join(runFlag, "orchestration", "RUNBOOK.md")} sequentially (the eco path).`,
1789
+ );
1790
+ }
1791
+ // Surface the valid phase names once, so a scripted caller can discover them without --help.
1792
+ if (p.flags.phase === undefined && workflows.length === 0 && p.flags.eco !== true) {
1793
+ const anyReady = res.phases.some((ph) => ph.ready);
1794
+ console.error(
1795
+ anyReady
1796
+ ? "ultra11y orchestrate: every ready phase has an empty worklist — nothing to fan out (see --list)."
1797
+ : `ultra11y orchestrate: no ready phase — phases are ${PHASES.join(", ")} (see --list).`,
1798
+ );
1799
+ }
1800
+ return 0;
1801
+ }
1802
+
1803
+ export async function main(argv: string[]): Promise<number> {
1804
+ const first = argv[0];
1805
+
1806
+ if (!first || first === "-h" || first === "--help") {
1807
+ console.log(HELP);
1808
+ return 0;
1809
+ }
1810
+ if (first === "-v" || first === "--version") {
1811
+ console.log(VERSION);
1812
+ return 0;
1813
+ }
1814
+ if (!isCommand(first)) {
1815
+ console.error(`ultra11y: unknown command "${first}". Run \`ultra11y --help\`.`);
1816
+ return 2;
1817
+ }
1818
+
1819
+ const p = parseArgs(argv);
1820
+ // `<cmd> --help` / `<cmd> -h` shows help with NO side effects. Without this every
1821
+ // subcommand ignored the flag and ran — and `init --help` would write a hook +
1822
+ // baseline into the cwd repo.
1823
+ if (p.flags.help === true || p.flags.h === true) {
1824
+ console.log(HELP);
1825
+ return 0;
1826
+ }
1827
+ // Warn (never silently ignore) on misspelled/unknown flags so `--grph` or
1828
+ // `--standrd rgaa` can't quietly leave cross-file/a standard disabled.
1829
+ for (const f of p.unknown) console.error(`ultra11y: unknown flag --${f} (ignored). Run \`ultra11y --help\`.`);
1830
+
1831
+ // Enum-valued flags: warn (never silently coerce) on an unsupported value so `--lang de`
1832
+ // or `--dedup fuzzy` is visible instead of quietly falling back to the default.
1833
+ const ENUM_FLAGS: Record<string, readonly string[]> = {
1834
+ lang: ["auto", "en", "fr"],
1835
+ dedup: ["exact", "normalized", "off"],
1836
+ format: ["audit", "doc", "remediation"],
1837
+ split: ["criterion"],
1838
+ runtime: ["auto", "local", "docker"],
1839
+ };
1840
+ for (const [flag, allowed] of Object.entries(ENUM_FLAGS)) {
1841
+ const v = p.flags[flag];
1842
+ if (typeof v === "string" && v && !allowed.includes(v)) console.error(`ultra11y: --${flag} "${v}" is not one of ${allowed.join("|")} — using the default.`);
1843
+ }
1844
+
1845
+ // Load any runtime standards packs (--pack / .ultra11yrc.json) BEFORE resolving
1846
+ // --standard, so an external pack is registered when stdOf/loadPack runs. A bad
1847
+ // config or an invalid pack is a hard error (never a silent skip).
1848
+ const packList =
1849
+ typeof p.flags.pack === "string"
1850
+ ? (p.flags.pack as string)
1851
+ .split(",")
1852
+ .map((s) => s.trim())
1853
+ .filter(Boolean)
1854
+ : [];
1855
+ const loaded = loadRuntimeStandards(process.cwd(), packList, (m) => console.error(m), p.flags.override === true);
1856
+ if (loaded.errors.length) {
1857
+ for (const e of loaded.errors) console.error(`ultra11y: ${e}`);
1858
+ return 2;
1859
+ }
1860
+ if (loaded.defaultStandard && p.flags.standard === undefined) p.flags.standard = loaded.defaultStandard;
1861
+
1862
+ switch (p.command as Command) {
1863
+ case "audit":
1864
+ return cmdAudit(p);
1865
+ case "report":
1866
+ return cmdReport(p);
1867
+ case "prd":
1868
+ return cmdPrd(p);
1869
+ case "render":
1870
+ return cmdRender(p);
1871
+ case "criteria":
1872
+ return cmdCriteria(p);
1873
+ case "check":
1874
+ return cmdCheck(p);
1875
+ case "verify":
1876
+ return cmdVerify(p);
1877
+ case "scan":
1878
+ return cmdScan(p);
1879
+ case "sample":
1880
+ return cmdSample(p);
1881
+ case "fix":
1882
+ return cmdFix(p);
1883
+ case "init":
1884
+ return cmdInit(p);
1885
+ case "pack":
1886
+ return cmdPack(p);
1887
+ case "orchestrate":
1888
+ return cmdOrchestrate(p);
1889
+ case "mcp":
1890
+ return cmdMcp(p);
1891
+ default:
1892
+ console.error(`ultra11y: "${p.command}" is not implemented yet`);
1893
+ return 1;
1894
+ }
1895
+ }
1896
+
1897
+ // Only run when invoked directly (node scripts/ultra11y.mjs), not when imported
1898
+ // by tests. Realpath both sides: Node canonicalizes import.meta.url but leaves
1899
+ // process.argv[1] as-typed, so on a symlinked path (macOS /tmp → /private/tmp)
1900
+ // a raw URL compare silently fails and main() never runs.
1901
+ function isInvokedDirectly(): boolean {
1902
+ const argv1 = process.argv[1];
1903
+ if (argv1 === undefined) return false;
1904
+ const modulePath = fileURLToPath(import.meta.url);
1905
+ try {
1906
+ if (realpathSync(argv1) === realpathSync(modulePath)) return true;
1907
+ } catch {
1908
+ /* a path may be virtual — fall through */
1909
+ }
1910
+ return import.meta.url === pathToFileURL(argv1).href;
1911
+ }
1912
+
1913
+ // Set `process.exitCode` — never `process.exit()`. On a PIPE, stdout is asynchronous,
1914
+ // so `process.exit()` tears the process down before the pending writes flush: a large
1915
+ // `audit --json | jq` used to receive exactly 65536 bytes of truncated, invalid JSON.
1916
+ // Letting the event loop drain naturally keeps the exit code AND the whole payload.
1917
+ if (isInvokedDirectly()) {
1918
+ main(process.argv.slice(2)).then(
1919
+ (code) => {
1920
+ process.exitCode = code;
1921
+ },
1922
+ (err: unknown) => {
1923
+ console.error(err instanceof Error ? err.message : err);
1924
+ process.exitCode = 1;
1925
+ },
1926
+ );
1927
+ }