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.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- 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
|
+
}
|