ultra11y 5.40.0 → 5.40.2
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/package.json +1 -1
- package/scripts/ultra11y.mjs +37 -8
- package/scripts/vendor/wcag-2.2-fr.json +1 -1
- package/scripts/vendor/wcag-2.2-sc-universe.json +1 -1
- package/scripts/vendor/wcag-2.2-techniques.json +1 -1
- package/scripts/vendor/wcag-2.2-text.json +1 -1
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +37 -8
- package/skills/ultra11y/SKILL.md +5 -1
- package/skills/ultra11y/references/runbook.md +3 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +37 -8
- package/skills/ultra11y/templates/gitlab-ci.yml +1 -1
- package/src/agent-cli.ts +30 -3
- package/src/cli.ts +3 -0
- package/src/data/wcag-universe.json +1 -1
- package/src/llm.ts +40 -1
- package/src/parse/html.ts +6 -2
- package/src/standards/derive.ts +9 -2
- package/src/types.ts +1 -1
- package/src/verdict-rules.ts +8 -1
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"name": "ultra11y",
|
|
11
11
|
"source": "./",
|
|
12
12
|
"description": "Automatic accessibility review before commit, push and pull request, plus detailed evidenced Markdown, HTML and per-page WCAG 2.2 AA / RGAA reports in Claude Code.",
|
|
13
|
-
"version": "5.40.
|
|
13
|
+
"version": "5.40.2",
|
|
14
14
|
"author": { "name": "maxgfr" },
|
|
15
15
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
16
16
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): automatic review before commit, push and pull request, plus detailed evidenced Markdown, HTML and per-page conformance reports in interactive agent sessions.",
|
|
4
|
-
"version": "5.40.
|
|
4
|
+
"version": "5.40.2",
|
|
5
5
|
"author": { "name": "maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
3
|
"description": "Accessibility for WCAG 2.2 AA and country standards (RGAA…): automatic review before commit, push and pull request, plus detailed evidenced Markdown, HTML and per-page conformance reports in interactive agent sessions.",
|
|
4
|
-
"version": "5.40.
|
|
4
|
+
"version": "5.40.2",
|
|
5
5
|
"author": { "name": "maxgfr", "url": "https://github.com/maxgfr" },
|
|
6
6
|
"homepage": "https://github.com/maxgfr/ultra11y",
|
|
7
7
|
"repository": "https://github.com/maxgfr/ultra11y",
|
|
@@ -27,7 +27,7 @@ import { fileURLToPath } from "node:url";
|
|
|
27
27
|
// Ownership marker: `ultra11y install --opencode` refuses to overwrite a plugin file that
|
|
28
28
|
// does not carry it, so a user's own ultra11y.js is never clobbered.
|
|
29
29
|
const ULTRA11Y_OPENCODE_PLUGIN = "ULTRA11Y_OPENCODE_PLUGIN";
|
|
30
|
-
const ULTRA11Y_PLUGIN_VERSION = "5.40.
|
|
30
|
+
const ULTRA11Y_PLUGIN_VERSION = "5.40.2";
|
|
31
31
|
|
|
32
32
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
33
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ultra11y",
|
|
3
|
-
"version": "5.40.
|
|
3
|
+
"version": "5.40.2",
|
|
4
4
|
"description": "Audit HTML/CSS/JSX against WCAG 2.2 AA and produce a dated compliance report, or author/review accessible markup (native-HTML-first). A skills.sh agent skill: an install-free static engine (bundled, no npm install) plus the agent's judgment, with check/verify gates against hallucinated non-conformities, and pluggable in-repo standards packs (RGAA France first; Section 508 & EN 301 549 next).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@10.33.0",
|
package/scripts/ultra11y.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { join as join53, relative as relative6, resolve as resolve16, sep as sep
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "5.40.
|
|
9
|
+
var VERSION = "5.40.2";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
var INAPPLICABLE_STATUS = "C";
|
|
12
12
|
|
|
@@ -2300,7 +2300,7 @@ var wcag_universe_default = {
|
|
|
2300
2300
|
wcagVersion: "2.2",
|
|
2301
2301
|
source: "https://www.w3.org/TR/WCAG22/",
|
|
2302
2302
|
criteriaSource: "https://github.com/w3c/wcag",
|
|
2303
|
-
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-
|
|
2303
|
+
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-31 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
|
|
2304
2304
|
criteria: [
|
|
2305
2305
|
{
|
|
2306
2306
|
id: "1.1.1",
|
|
@@ -5473,8 +5473,8 @@ function allIds(doc) {
|
|
|
5473
5473
|
}
|
|
5474
5474
|
function snippet(doc, el, max = 120) {
|
|
5475
5475
|
const lineStart = doc.lineStarts[el.line - 1] ?? 0;
|
|
5476
|
-
|
|
5477
|
-
|
|
5476
|
+
const nextLineStart = doc.lineStarts[el.line];
|
|
5477
|
+
const end = nextLineStart === void 0 ? doc.source.length : Math.max(lineStart, nextLineStart - 1);
|
|
5478
5478
|
const from = Math.min(Math.max(el.start, lineStart), end);
|
|
5479
5479
|
const tagEnd = doc.source.indexOf(">", from);
|
|
5480
5480
|
const to = Math.min(from + max, Math.max(Math.min(end, from + max), tagEnd === -1 ? end : tagEnd + 1));
|
|
@@ -56992,7 +56992,7 @@ function judgmentGuard(r, pc) {
|
|
|
56992
56992
|
return { ...r, status: "manual", judgment: true };
|
|
56993
56993
|
}
|
|
56994
56994
|
function measuredRescue(r, pc, cov, ran, pageId) {
|
|
56995
|
-
if (r.status !== "manual" || pc.judgment || r.outOfScope || pc.automation
|
|
56995
|
+
if (r.status !== "manual" || pc.judgment || r.outOfScope || pc.automation && pc.automation.completeBySilence !== true) return r;
|
|
56996
56996
|
if (!criterionMeasuredOn(pc.appliesTo?.ruleIds, pc.wcag, cov, ran)) return r;
|
|
56997
56997
|
const { scopedOut: _scopedOut, judgment: _judgment, ...rest } = r;
|
|
56998
56998
|
return { ...rest, status: "C", decidedBy: "scan", justification: measuredReason(pc, pageId) };
|
|
@@ -63277,6 +63277,7 @@ var VERDICT_KINDS = `Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict mis
|
|
|
63277
63277
|
- \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected, and so is a finding with no \`file\` at all. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the criterion's OWN numbered tests, listed in its brief under \xAB tests to rule on \xBB. A WCAG id looks alike, denotes an unrelated test, and is rejected.
|
|
63278
63278
|
- \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
|
|
63279
63279
|
- \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide it, and no capture in this run carries its subject) or \`undecidable\` (the evidence cannot settle it either way).`;
|
|
63280
|
+
var WORKLIST_RULE = `THE WORKLIST IS THE RESIDUAL. Return exactly one verdict for EVERY criterion presented \u2014 never omit an item because the engine already reported a run-wide \`NC\`. The strict page gate deliberately puts such a criterion back on the worklist when page-level cells remain open. It is not surplus and it is not already resolved for this task: inspect the complete evidence and rule it. If the evidence cannot settle it, return \`manual\` with a reason; never leave its verdict blank.`;
|
|
63280
63281
|
var ABSENCE_RULE = `AN NC SHAPED LIKE AN ABSENCE IS STILL ANCHORED. \xAB No second navigation system \xBB, \xAB no search engine \xBB, \xAB no error message suggests the expected format \xBB \u2014 an absence is OBSERVED somewhere: cite the element and the page you observed it on. And when the criterion's subject exists nowhere in the audited scope, the verdict is \`NA\` with its justification, never \`NC\`.
|
|
63281
63282
|
|
|
63282
63283
|
AND CITE FROM THIS CRITERION'S OWN ANCHORS, NOT THE THING YOU ARE RULING OUT. An absence pulls you toward the element you are arguing ABOUT \u2014 the search form that is not a site search engine, the menu that is not a second navigation system \u2014 and that element is, precisely because it is off-topic, absent from what this criterion was harvested. A citation on one of the criterion's own anchors is vouched for by the harvest; one outside it has its snippet verified LITERALLY, character for character, and a retyping then fails. Cite the region you inspected \u2014 the \`header\`, the \`nav\`, the \`footer\` the brief listed \u2014 and say in the justification what you did not find in it.`;
|
|
@@ -63284,7 +63285,7 @@ var CAPTURE_RULE = `THE RENDERED PAGE MAY BE ON DISK. When a criterion's evidenc
|
|
|
63284
63285
|
var NEVER_GUESS_RULE = `Never guess. A criterion you cannot decide from real evidence stays \`manual\` with its reason \u2014 that is a valid, honest verdict, and it is worth more than a verdict the gate throws away.`;
|
|
63285
63286
|
var SCOPE_RULE = `Rule ONLY on the criteria presented. Never introduce another \u2014 a verdict for a criterion nobody asked about is dropped, and under the fold it would otherwise overwrite what the deterministic engine already decided.`;
|
|
63286
63287
|
var CONFORMITY_RULE = `A \`C\` WILL BE ATTACKED, exactly as an \`NC\` is. Every conformity you record goes into an adversarial worklist where a second reader opens your citations and asks whether they ESTABLISH the criterion or merely show that its subject exists \u2014 a present \`alt\` is not a relevant \`alt\`, a present \`<title>\` is not a title that describes the page. Cite the evidence that answers the criterion's own question, and when the evidence only proves presence, the honest verdict is \`manual\`.`;
|
|
63287
|
-
var TAIL = [ABSENCE_RULE, CAPTURE_RULE, NEVER_GUESS_RULE, SCOPE_RULE, CONFORMITY_RULE];
|
|
63288
|
+
var TAIL = [WORKLIST_RULE, ABSENCE_RULE, CAPTURE_RULE, NEVER_GUESS_RULE, SCOPE_RULE, CONFORMITY_RULE];
|
|
63288
63289
|
function verdictRulesMd(startAt) {
|
|
63289
63290
|
const lines = [`${startAt}. ${VERDICT_KINDS}`];
|
|
63290
63291
|
TAIL.forEach((rule, i2) => lines.push(`${startAt + 1 + i2}. ${rule}`));
|
|
@@ -63328,6 +63329,12 @@ function apiKeyFromEnv() {
|
|
|
63328
63329
|
function modelFromEnv() {
|
|
63329
63330
|
return process.env.ULTRA11Y_LLM_MODEL?.trim() || DEFAULT_MODEL;
|
|
63330
63331
|
}
|
|
63332
|
+
var BudgetExceededError = class extends Error {
|
|
63333
|
+
constructor(message) {
|
|
63334
|
+
super(message);
|
|
63335
|
+
this.name = "BudgetExceededError";
|
|
63336
|
+
}
|
|
63337
|
+
};
|
|
63331
63338
|
function isProviderUnavailableError(error) {
|
|
63332
63339
|
const message = error instanceof Error ? error.message : String(error);
|
|
63333
63340
|
return /(?:api status|http)\s*(?:429|5\d\d)|\b429\b.*rate.?limit|rate.?limit|overload|service unavailable/i.test(message);
|
|
@@ -63480,6 +63487,7 @@ async function judgeAll(batches, opts) {
|
|
|
63480
63487
|
const verdicts = [];
|
|
63481
63488
|
const failures = [];
|
|
63482
63489
|
let done = 0;
|
|
63490
|
+
let total = batches.length;
|
|
63483
63491
|
const queue = [...batches];
|
|
63484
63492
|
const backend = opts.backend ?? judgeBatch;
|
|
63485
63493
|
const lanes = Math.max(1, opts.concurrency ?? CONCURRENCY);
|
|
@@ -63497,10 +63505,18 @@ async function judgeAll(batches, opts) {
|
|
|
63497
63505
|
verdicts.push(...checked.accepted);
|
|
63498
63506
|
if (checked.accepted.length) opts.onVerdicts?.(checked.accepted);
|
|
63499
63507
|
} catch (e) {
|
|
63508
|
+
if (e instanceof BudgetExceededError && b.items.length > 1 && opts.render) {
|
|
63509
|
+
const half = Math.ceil(b.items.length / 2);
|
|
63510
|
+
const parts2 = [b.items.slice(0, half), b.items.slice(half)];
|
|
63511
|
+
queue.unshift(...parts2.map((items) => ({ items, prompt: opts.render(items) })));
|
|
63512
|
+
total += parts2.length - 1;
|
|
63513
|
+
opts.onProgress?.(done, total);
|
|
63514
|
+
continue;
|
|
63515
|
+
}
|
|
63500
63516
|
failures.push(e instanceof Error ? e.message : String(e));
|
|
63501
63517
|
if (opts.abortOnError?.(e)) aborted = true;
|
|
63502
63518
|
}
|
|
63503
|
-
opts.onProgress?.(++done,
|
|
63519
|
+
opts.onProgress?.(++done, total);
|
|
63504
63520
|
}
|
|
63505
63521
|
})
|
|
63506
63522
|
);
|
|
@@ -63572,7 +63588,9 @@ function claudeBin() {
|
|
|
63572
63588
|
}
|
|
63573
63589
|
function batchSchema(items) {
|
|
63574
63590
|
const schema = structuredClone(VERDICT_TOOL.input_schema);
|
|
63575
|
-
const
|
|
63591
|
+
const verdicts = schema.properties.verdicts;
|
|
63592
|
+
verdicts.description = `Exactly one verdict for each of these ${items.length} worklist criteria. Do not omit criteria already reported non-conforming run-wide: page-level cells may still be open.`;
|
|
63593
|
+
const id = verdicts.items.properties.criteriaId;
|
|
63576
63594
|
id.enum = items.map((i2) => i2.criteriaId);
|
|
63577
63595
|
id.description = `The criterion id EXACTLY as given \u2014 the bare id (e.g. "1.2.1"), never the heading or the title beside it.`;
|
|
63578
63596
|
return schema;
|
|
@@ -63704,6 +63722,14 @@ async function runCli2(argv, prompt, opts, extract) {
|
|
|
63704
63722
|
}
|
|
63705
63723
|
if (env.is_error || env.subtype !== "success") {
|
|
63706
63724
|
lastError = `the CLI reported an error (${env.subtype ?? "unknown"}${env.api_error_status ? `, api status ${env.api_error_status}` : ""}).`;
|
|
63725
|
+
if (env.subtype === "error_max_budget_usd") {
|
|
63726
|
+
try {
|
|
63727
|
+
const salvaged = extract(env);
|
|
63728
|
+
if (Array.isArray(salvaged) ? salvaged.length > 0 : salvaged !== void 0) return salvaged;
|
|
63729
|
+
} catch {
|
|
63730
|
+
}
|
|
63731
|
+
throw new BudgetExceededError(`ultra11y judge: ${lastError}`);
|
|
63732
|
+
}
|
|
63707
63733
|
if (env.api_error_status && (env.api_error_status === 429 || env.api_error_status >= 500)) continue;
|
|
63708
63734
|
break;
|
|
63709
63735
|
}
|
|
@@ -74907,6 +74933,9 @@ async function cmdJudge(p) {
|
|
|
74907
74933
|
// runner with room.
|
|
74908
74934
|
concurrency: runner === "claude" || runner === "codex" ? cliConcurrency : void 0,
|
|
74909
74935
|
abortOnError: isProviderUnavailableError,
|
|
74936
|
+
// The seam that lets a budget abort be halved rather than lost: the same renderer
|
|
74937
|
+
// `batchWorklist` used, so a re-queued half is prompted exactly like a first-class batch.
|
|
74938
|
+
render: render2,
|
|
74910
74939
|
maxBudgetUsd: Number.isFinite(maxBudgetUsd) ? maxBudgetUsd : void 0,
|
|
74911
74940
|
effort,
|
|
74912
74941
|
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : void 0,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"wcagVersion": "2.2",
|
|
3
3
|
"source": "https://www.w3.org/TR/WCAG22/",
|
|
4
4
|
"criteriaSource": "https://github.com/w3c/wcag",
|
|
5
|
-
"provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-
|
|
5
|
+
"provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-31 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
|
|
6
6
|
"principles": [
|
|
7
7
|
{
|
|
8
8
|
"number": 1,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"source": "https://github.com/w3c/wcag",
|
|
3
3
|
"understanding": "https://www.w3.org/WAI/WCAG22/Understanding/",
|
|
4
4
|
"license": "W3C Document License (code samples: W3C Software License)",
|
|
5
|
-
"fetchedAt": "2026-08-
|
|
5
|
+
"fetchedAt": "2026-08-31",
|
|
6
6
|
"criteria": {
|
|
7
7
|
"1.1.1": {
|
|
8
8
|
"slug": "non-text-content",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": "https://github.com/w3c/wcag",
|
|
3
3
|
"license": "W3C Document License",
|
|
4
|
-
"fetchedAt": "2026-08-
|
|
4
|
+
"fetchedAt": "2026-08-31",
|
|
5
5
|
"criteria": {
|
|
6
6
|
"1.1.1": "All non-text content that is presented to the user has a text alternative that serves the equivalent purpose, except for the situations listed below.\nControls, Input:\nIf non-text content is a control or accepts user input, then it has a name that describes its purpose. (Refer to Success Criterion 4.1.2 Name, Role, Value for additional requirements for controls and content that accepts user input.)\nTime-Based Media:\nIf non-text content is time-based media, then text alternatives at least provide descriptive identification of the non-text content. (Refer to Guideline 1.2 for additional requirements for media.)\nTest:\nIf non-text content is a test or exercise that would be invalid if presented in text, then text alternatives at least provide descriptive identification of the non-text content.\nSensory:\nIf non-text content is primarily intended to create a specific sensory experience, then text alternatives at least provide descriptive identification of the non-text content.\nCAPTCHA:\nIf the purpose of non-text content is to confirm that content is being accessed by a person rather than a computer, then text alternatives that identify and describe the purpose of the non-text content are provided, and alternative forms of CAPTCHA using output modes for different types of sensory perception are provided to accommodate different disabilities.\nDecoration, Formatting, Invisible:\nIf non-text content is pure decoration, is used only for visual formatting, or is not presented to users, then it is implemented in a way that it can be ignored by assistive technology.",
|
|
7
7
|
"1.2.1": "For prerecorded audio-only and prerecorded video-only media, the following are true, except when the audio or video is a media alternative for text and is clearly labeled as such:\nPrerecorded Audio-only:\nAn alternative for time-based media is provided that presents equivalent information for prerecorded audio-only content.\nPrerecorded Video-only:\nEither an alternative for time-based media or an audio track is provided that presents equivalent information for prerecorded video-only content.",
|
|
@@ -3,7 +3,7 @@ name: review-a11y
|
|
|
3
3
|
description: "Use to REVIEW changed frontend code for accessibility: staged files, a working diff, branch, or PR; prompts such as 'review a11y', 'is this accessible?', or 'anything to fix before merge?'; and hook or CI findings on a change. Run the bundled install-free engine with cross-file JSX/TSX analysis, then adjudicate candidates and return a severity-ranked WCAG 2.2 AA review with file:line fixes, residual rendering risks, and a verdict. Use `ultra11y` instead for whole-repo audits, dated conformance reports, country standards, PRD backlogs, or authoring accessible markup."
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
|
-
version: 5.40.
|
|
6
|
+
version: 5.40.2
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# review-a11y — accessibility review of changed code
|
|
@@ -6,7 +6,7 @@ import { join as join53, relative as relative6, resolve as resolve16, sep as sep
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "5.40.
|
|
9
|
+
var VERSION = "5.40.2";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
var INAPPLICABLE_STATUS = "C";
|
|
12
12
|
|
|
@@ -2300,7 +2300,7 @@ var wcag_universe_default = {
|
|
|
2300
2300
|
wcagVersion: "2.2",
|
|
2301
2301
|
source: "https://www.w3.org/TR/WCAG22/",
|
|
2302
2302
|
criteriaSource: "https://github.com/w3c/wcag",
|
|
2303
|
-
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-
|
|
2303
|
+
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-31 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
|
|
2304
2304
|
criteria: [
|
|
2305
2305
|
{
|
|
2306
2306
|
id: "1.1.1",
|
|
@@ -5473,8 +5473,8 @@ function allIds(doc) {
|
|
|
5473
5473
|
}
|
|
5474
5474
|
function snippet(doc, el, max = 120) {
|
|
5475
5475
|
const lineStart = doc.lineStarts[el.line - 1] ?? 0;
|
|
5476
|
-
|
|
5477
|
-
|
|
5476
|
+
const nextLineStart = doc.lineStarts[el.line];
|
|
5477
|
+
const end = nextLineStart === void 0 ? doc.source.length : Math.max(lineStart, nextLineStart - 1);
|
|
5478
5478
|
const from = Math.min(Math.max(el.start, lineStart), end);
|
|
5479
5479
|
const tagEnd = doc.source.indexOf(">", from);
|
|
5480
5480
|
const to = Math.min(from + max, Math.max(Math.min(end, from + max), tagEnd === -1 ? end : tagEnd + 1));
|
|
@@ -56992,7 +56992,7 @@ function judgmentGuard(r, pc) {
|
|
|
56992
56992
|
return { ...r, status: "manual", judgment: true };
|
|
56993
56993
|
}
|
|
56994
56994
|
function measuredRescue(r, pc, cov, ran, pageId) {
|
|
56995
|
-
if (r.status !== "manual" || pc.judgment || r.outOfScope || pc.automation
|
|
56995
|
+
if (r.status !== "manual" || pc.judgment || r.outOfScope || pc.automation && pc.automation.completeBySilence !== true) return r;
|
|
56996
56996
|
if (!criterionMeasuredOn(pc.appliesTo?.ruleIds, pc.wcag, cov, ran)) return r;
|
|
56997
56997
|
const { scopedOut: _scopedOut, judgment: _judgment, ...rest } = r;
|
|
56998
56998
|
return { ...rest, status: "C", decidedBy: "scan", justification: measuredReason(pc, pageId) };
|
|
@@ -63277,6 +63277,7 @@ var VERDICT_KINDS = `Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict mis
|
|
|
63277
63277
|
- \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected, and so is a finding with no \`file\` at all. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the criterion's OWN numbered tests, listed in its brief under \xAB tests to rule on \xBB. A WCAG id looks alike, denotes an unrelated test, and is rejected.
|
|
63278
63278
|
- \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
|
|
63279
63279
|
- \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide it, and no capture in this run carries its subject) or \`undecidable\` (the evidence cannot settle it either way).`;
|
|
63280
|
+
var WORKLIST_RULE = `THE WORKLIST IS THE RESIDUAL. Return exactly one verdict for EVERY criterion presented \u2014 never omit an item because the engine already reported a run-wide \`NC\`. The strict page gate deliberately puts such a criterion back on the worklist when page-level cells remain open. It is not surplus and it is not already resolved for this task: inspect the complete evidence and rule it. If the evidence cannot settle it, return \`manual\` with a reason; never leave its verdict blank.`;
|
|
63280
63281
|
var ABSENCE_RULE = `AN NC SHAPED LIKE AN ABSENCE IS STILL ANCHORED. \xAB No second navigation system \xBB, \xAB no search engine \xBB, \xAB no error message suggests the expected format \xBB \u2014 an absence is OBSERVED somewhere: cite the element and the page you observed it on. And when the criterion's subject exists nowhere in the audited scope, the verdict is \`NA\` with its justification, never \`NC\`.
|
|
63281
63282
|
|
|
63282
63283
|
AND CITE FROM THIS CRITERION'S OWN ANCHORS, NOT THE THING YOU ARE RULING OUT. An absence pulls you toward the element you are arguing ABOUT \u2014 the search form that is not a site search engine, the menu that is not a second navigation system \u2014 and that element is, precisely because it is off-topic, absent from what this criterion was harvested. A citation on one of the criterion's own anchors is vouched for by the harvest; one outside it has its snippet verified LITERALLY, character for character, and a retyping then fails. Cite the region you inspected \u2014 the \`header\`, the \`nav\`, the \`footer\` the brief listed \u2014 and say in the justification what you did not find in it.`;
|
|
@@ -63284,7 +63285,7 @@ var CAPTURE_RULE = `THE RENDERED PAGE MAY BE ON DISK. When a criterion's evidenc
|
|
|
63284
63285
|
var NEVER_GUESS_RULE = `Never guess. A criterion you cannot decide from real evidence stays \`manual\` with its reason \u2014 that is a valid, honest verdict, and it is worth more than a verdict the gate throws away.`;
|
|
63285
63286
|
var SCOPE_RULE = `Rule ONLY on the criteria presented. Never introduce another \u2014 a verdict for a criterion nobody asked about is dropped, and under the fold it would otherwise overwrite what the deterministic engine already decided.`;
|
|
63286
63287
|
var CONFORMITY_RULE = `A \`C\` WILL BE ATTACKED, exactly as an \`NC\` is. Every conformity you record goes into an adversarial worklist where a second reader opens your citations and asks whether they ESTABLISH the criterion or merely show that its subject exists \u2014 a present \`alt\` is not a relevant \`alt\`, a present \`<title>\` is not a title that describes the page. Cite the evidence that answers the criterion's own question, and when the evidence only proves presence, the honest verdict is \`manual\`.`;
|
|
63287
|
-
var TAIL = [ABSENCE_RULE, CAPTURE_RULE, NEVER_GUESS_RULE, SCOPE_RULE, CONFORMITY_RULE];
|
|
63288
|
+
var TAIL = [WORKLIST_RULE, ABSENCE_RULE, CAPTURE_RULE, NEVER_GUESS_RULE, SCOPE_RULE, CONFORMITY_RULE];
|
|
63288
63289
|
function verdictRulesMd(startAt) {
|
|
63289
63290
|
const lines = [`${startAt}. ${VERDICT_KINDS}`];
|
|
63290
63291
|
TAIL.forEach((rule, i2) => lines.push(`${startAt + 1 + i2}. ${rule}`));
|
|
@@ -63328,6 +63329,12 @@ function apiKeyFromEnv() {
|
|
|
63328
63329
|
function modelFromEnv() {
|
|
63329
63330
|
return process.env.ULTRA11Y_LLM_MODEL?.trim() || DEFAULT_MODEL;
|
|
63330
63331
|
}
|
|
63332
|
+
var BudgetExceededError = class extends Error {
|
|
63333
|
+
constructor(message) {
|
|
63334
|
+
super(message);
|
|
63335
|
+
this.name = "BudgetExceededError";
|
|
63336
|
+
}
|
|
63337
|
+
};
|
|
63331
63338
|
function isProviderUnavailableError(error) {
|
|
63332
63339
|
const message = error instanceof Error ? error.message : String(error);
|
|
63333
63340
|
return /(?:api status|http)\s*(?:429|5\d\d)|\b429\b.*rate.?limit|rate.?limit|overload|service unavailable/i.test(message);
|
|
@@ -63480,6 +63487,7 @@ async function judgeAll(batches, opts) {
|
|
|
63480
63487
|
const verdicts = [];
|
|
63481
63488
|
const failures = [];
|
|
63482
63489
|
let done = 0;
|
|
63490
|
+
let total = batches.length;
|
|
63483
63491
|
const queue = [...batches];
|
|
63484
63492
|
const backend = opts.backend ?? judgeBatch;
|
|
63485
63493
|
const lanes = Math.max(1, opts.concurrency ?? CONCURRENCY);
|
|
@@ -63497,10 +63505,18 @@ async function judgeAll(batches, opts) {
|
|
|
63497
63505
|
verdicts.push(...checked.accepted);
|
|
63498
63506
|
if (checked.accepted.length) opts.onVerdicts?.(checked.accepted);
|
|
63499
63507
|
} catch (e) {
|
|
63508
|
+
if (e instanceof BudgetExceededError && b.items.length > 1 && opts.render) {
|
|
63509
|
+
const half = Math.ceil(b.items.length / 2);
|
|
63510
|
+
const parts2 = [b.items.slice(0, half), b.items.slice(half)];
|
|
63511
|
+
queue.unshift(...parts2.map((items) => ({ items, prompt: opts.render(items) })));
|
|
63512
|
+
total += parts2.length - 1;
|
|
63513
|
+
opts.onProgress?.(done, total);
|
|
63514
|
+
continue;
|
|
63515
|
+
}
|
|
63500
63516
|
failures.push(e instanceof Error ? e.message : String(e));
|
|
63501
63517
|
if (opts.abortOnError?.(e)) aborted = true;
|
|
63502
63518
|
}
|
|
63503
|
-
opts.onProgress?.(++done,
|
|
63519
|
+
opts.onProgress?.(++done, total);
|
|
63504
63520
|
}
|
|
63505
63521
|
})
|
|
63506
63522
|
);
|
|
@@ -63572,7 +63588,9 @@ function claudeBin() {
|
|
|
63572
63588
|
}
|
|
63573
63589
|
function batchSchema(items) {
|
|
63574
63590
|
const schema = structuredClone(VERDICT_TOOL.input_schema);
|
|
63575
|
-
const
|
|
63591
|
+
const verdicts = schema.properties.verdicts;
|
|
63592
|
+
verdicts.description = `Exactly one verdict for each of these ${items.length} worklist criteria. Do not omit criteria already reported non-conforming run-wide: page-level cells may still be open.`;
|
|
63593
|
+
const id = verdicts.items.properties.criteriaId;
|
|
63576
63594
|
id.enum = items.map((i2) => i2.criteriaId);
|
|
63577
63595
|
id.description = `The criterion id EXACTLY as given \u2014 the bare id (e.g. "1.2.1"), never the heading or the title beside it.`;
|
|
63578
63596
|
return schema;
|
|
@@ -63704,6 +63722,14 @@ async function runCli2(argv, prompt, opts, extract) {
|
|
|
63704
63722
|
}
|
|
63705
63723
|
if (env.is_error || env.subtype !== "success") {
|
|
63706
63724
|
lastError = `the CLI reported an error (${env.subtype ?? "unknown"}${env.api_error_status ? `, api status ${env.api_error_status}` : ""}).`;
|
|
63725
|
+
if (env.subtype === "error_max_budget_usd") {
|
|
63726
|
+
try {
|
|
63727
|
+
const salvaged = extract(env);
|
|
63728
|
+
if (Array.isArray(salvaged) ? salvaged.length > 0 : salvaged !== void 0) return salvaged;
|
|
63729
|
+
} catch {
|
|
63730
|
+
}
|
|
63731
|
+
throw new BudgetExceededError(`ultra11y judge: ${lastError}`);
|
|
63732
|
+
}
|
|
63707
63733
|
if (env.api_error_status && (env.api_error_status === 429 || env.api_error_status >= 500)) continue;
|
|
63708
63734
|
break;
|
|
63709
63735
|
}
|
|
@@ -74907,6 +74933,9 @@ async function cmdJudge(p) {
|
|
|
74907
74933
|
// runner with room.
|
|
74908
74934
|
concurrency: runner === "claude" || runner === "codex" ? cliConcurrency : void 0,
|
|
74909
74935
|
abortOnError: isProviderUnavailableError,
|
|
74936
|
+
// The seam that lets a budget abort be halved rather than lost: the same renderer
|
|
74937
|
+
// `batchWorklist` used, so a re-queued half is prompted exactly like a first-class batch.
|
|
74938
|
+
render: render2,
|
|
74910
74939
|
maxBudgetUsd: Number.isFinite(maxBudgetUsd) ? maxBudgetUsd : void 0,
|
|
74911
74940
|
effort,
|
|
74912
74941
|
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : void 0,
|
package/skills/ultra11y/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: ultra11y
|
|
|
3
3
|
description: "Use to AUDIT a repository, site, or page against WCAG 2.2 AA or a country standard such as RGAA; produce a dated conformance report, criterion grid, PRD backlog, or tickets; AUTHOR accessible markup; fix accessibility; or wire a repo gate and rendered-DOM capture pipeline. The bundled install-free engine runs 93 static checks tied to success criteria, while the agent adjudicates judgment criteria and routes rendering criteria to browser scans; check/verify reject invented non-conformities. Use `review-a11y` instead for a review scoped only to a diff, branch, or PR."
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
|
-
version: 5.40.
|
|
6
|
+
version: 5.40.2
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# ultra11y — audit WCAG 2.2 AA and write accessible markup
|
|
@@ -72,6 +72,10 @@ adjudicates. So the engine's clean run is a starting point, never a verdict —
|
|
|
72
72
|
> grid to the same bar: a criterion failing on one route is settled for the run and may still
|
|
73
73
|
> be nobody's verdict on the routes it never fired on, which is what a per-page deliverable is
|
|
74
74
|
> actually judged on.
|
|
75
|
+
> **The emitted worklist is authoritative.** It may deliberately contain a criterion whose
|
|
76
|
+
> run-wide status is already `NC`, because that failure did not decide the criterion on the
|
|
77
|
+
> other pages. Such an item is not redundant: return one grounded verdict for it (or an
|
|
78
|
+
> explicit `manual` reason), never leave it blank because the global tally looks decided.
|
|
75
79
|
> 5. **Look the criterion up; never recall it.** `criteria [--standard <pack>] <id>` — or
|
|
76
80
|
> `ultra11y_criteria` over MCP — returns the criterion's own wording, its **numbered
|
|
77
81
|
> tests**, and the glossary definitions those tests cite. RGAA 8.3 is not "the page needs a
|
|
@@ -22,6 +22,9 @@ for pass in 1 2 3; do
|
|
|
22
22
|
--model haiku --max-budget-usd <n> --concurrency 4 --ledger
|
|
23
23
|
done
|
|
24
24
|
|
|
25
|
+
# The worklist is authoritative. A run-wide NC may be present because other page cells remain
|
|
26
|
+
# open: every emitted item gets one grounded verdict, or an explicit manual reason.
|
|
27
|
+
|
|
25
28
|
# 4. A second reader puts those claims on trial, and the outcome is APPLIED.
|
|
26
29
|
ultra11y report --in audits/audit-latest.json --standard rgaa --out audits
|
|
27
30
|
ultra11y verify --report audits/<report>.md --in audits/audit-latest.json --out audits --max-verify 0
|
|
@@ -6,7 +6,7 @@ import { join as join53, relative as relative6, resolve as resolve16, sep as sep
|
|
|
6
6
|
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL3 } from "url";
|
|
7
7
|
|
|
8
8
|
// src/types.ts
|
|
9
|
-
var VERSION = "5.40.
|
|
9
|
+
var VERSION = "5.40.2";
|
|
10
10
|
var SCHEMA_VERSION = 2;
|
|
11
11
|
var INAPPLICABLE_STATUS = "C";
|
|
12
12
|
|
|
@@ -2300,7 +2300,7 @@ var wcag_universe_default = {
|
|
|
2300
2300
|
wcagVersion: "2.2",
|
|
2301
2301
|
source: "https://www.w3.org/TR/WCAG22/",
|
|
2302
2302
|
criteriaSource: "https://github.com/w3c/wcag",
|
|
2303
|
-
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-
|
|
2303
|
+
provenance: "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-31 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
|
|
2304
2304
|
criteria: [
|
|
2305
2305
|
{
|
|
2306
2306
|
id: "1.1.1",
|
|
@@ -5473,8 +5473,8 @@ function allIds(doc) {
|
|
|
5473
5473
|
}
|
|
5474
5474
|
function snippet(doc, el, max = 120) {
|
|
5475
5475
|
const lineStart = doc.lineStarts[el.line - 1] ?? 0;
|
|
5476
|
-
|
|
5477
|
-
|
|
5476
|
+
const nextLineStart = doc.lineStarts[el.line];
|
|
5477
|
+
const end = nextLineStart === void 0 ? doc.source.length : Math.max(lineStart, nextLineStart - 1);
|
|
5478
5478
|
const from = Math.min(Math.max(el.start, lineStart), end);
|
|
5479
5479
|
const tagEnd = doc.source.indexOf(">", from);
|
|
5480
5480
|
const to = Math.min(from + max, Math.max(Math.min(end, from + max), tagEnd === -1 ? end : tagEnd + 1));
|
|
@@ -56992,7 +56992,7 @@ function judgmentGuard(r, pc) {
|
|
|
56992
56992
|
return { ...r, status: "manual", judgment: true };
|
|
56993
56993
|
}
|
|
56994
56994
|
function measuredRescue(r, pc, cov, ran, pageId) {
|
|
56995
|
-
if (r.status !== "manual" || pc.judgment || r.outOfScope || pc.automation
|
|
56995
|
+
if (r.status !== "manual" || pc.judgment || r.outOfScope || pc.automation && pc.automation.completeBySilence !== true) return r;
|
|
56996
56996
|
if (!criterionMeasuredOn(pc.appliesTo?.ruleIds, pc.wcag, cov, ran)) return r;
|
|
56997
56997
|
const { scopedOut: _scopedOut, judgment: _judgment, ...rest } = r;
|
|
56998
56998
|
return { ...rest, status: "C", decidedBy: "scan", justification: measuredReason(pc, pageId) };
|
|
@@ -63277,6 +63277,7 @@ var VERDICT_KINDS = `Rule it (the apply gate is FAIL-CLOSED \u2014 a verdict mis
|
|
|
63277
63277
|
- \`NC\` (non-conforming) \u2014 REQUIRES \`findings\`: at least one groundable \`{ file, line, selector?, message, snippet?, severity?, normativeRef }\` pointing at REAL source. The fold re-grounds every finding; an invented file:line is rejected, and so is a finding with no \`file\` at all. \`normativeRef\` MUST cite the precise failed test \u2014 under a country standard, one of the criterion's OWN numbered tests, listed in its brief under \xAB tests to rule on \xBB. A WCAG id looks alike, denotes an unrelated test, and is rejected.
|
|
63278
63278
|
- \`NA\` (not applicable) \u2014 REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
|
|
63279
63279
|
- \`manual\` (still undecidable) \u2014 REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide it, and no capture in this run carries its subject) or \`undecidable\` (the evidence cannot settle it either way).`;
|
|
63280
|
+
var WORKLIST_RULE = `THE WORKLIST IS THE RESIDUAL. Return exactly one verdict for EVERY criterion presented \u2014 never omit an item because the engine already reported a run-wide \`NC\`. The strict page gate deliberately puts such a criterion back on the worklist when page-level cells remain open. It is not surplus and it is not already resolved for this task: inspect the complete evidence and rule it. If the evidence cannot settle it, return \`manual\` with a reason; never leave its verdict blank.`;
|
|
63280
63281
|
var ABSENCE_RULE = `AN NC SHAPED LIKE AN ABSENCE IS STILL ANCHORED. \xAB No second navigation system \xBB, \xAB no search engine \xBB, \xAB no error message suggests the expected format \xBB \u2014 an absence is OBSERVED somewhere: cite the element and the page you observed it on. And when the criterion's subject exists nowhere in the audited scope, the verdict is \`NA\` with its justification, never \`NC\`.
|
|
63281
63282
|
|
|
63282
63283
|
AND CITE FROM THIS CRITERION'S OWN ANCHORS, NOT THE THING YOU ARE RULING OUT. An absence pulls you toward the element you are arguing ABOUT \u2014 the search form that is not a site search engine, the menu that is not a second navigation system \u2014 and that element is, precisely because it is off-topic, absent from what this criterion was harvested. A citation on one of the criterion's own anchors is vouched for by the harvest; one outside it has its snippet verified LITERALLY, character for character, and a retyping then fails. Cite the region you inspected \u2014 the \`header\`, the \`nav\`, the \`footer\` the brief listed \u2014 and say in the justification what you did not find in it.`;
|
|
@@ -63284,7 +63285,7 @@ var CAPTURE_RULE = `THE RENDERED PAGE MAY BE ON DISK. When a criterion's evidenc
|
|
|
63284
63285
|
var NEVER_GUESS_RULE = `Never guess. A criterion you cannot decide from real evidence stays \`manual\` with its reason \u2014 that is a valid, honest verdict, and it is worth more than a verdict the gate throws away.`;
|
|
63285
63286
|
var SCOPE_RULE = `Rule ONLY on the criteria presented. Never introduce another \u2014 a verdict for a criterion nobody asked about is dropped, and under the fold it would otherwise overwrite what the deterministic engine already decided.`;
|
|
63286
63287
|
var CONFORMITY_RULE = `A \`C\` WILL BE ATTACKED, exactly as an \`NC\` is. Every conformity you record goes into an adversarial worklist where a second reader opens your citations and asks whether they ESTABLISH the criterion or merely show that its subject exists \u2014 a present \`alt\` is not a relevant \`alt\`, a present \`<title>\` is not a title that describes the page. Cite the evidence that answers the criterion's own question, and when the evidence only proves presence, the honest verdict is \`manual\`.`;
|
|
63287
|
-
var TAIL = [ABSENCE_RULE, CAPTURE_RULE, NEVER_GUESS_RULE, SCOPE_RULE, CONFORMITY_RULE];
|
|
63288
|
+
var TAIL = [WORKLIST_RULE, ABSENCE_RULE, CAPTURE_RULE, NEVER_GUESS_RULE, SCOPE_RULE, CONFORMITY_RULE];
|
|
63288
63289
|
function verdictRulesMd(startAt) {
|
|
63289
63290
|
const lines = [`${startAt}. ${VERDICT_KINDS}`];
|
|
63290
63291
|
TAIL.forEach((rule, i2) => lines.push(`${startAt + 1 + i2}. ${rule}`));
|
|
@@ -63328,6 +63329,12 @@ function apiKeyFromEnv() {
|
|
|
63328
63329
|
function modelFromEnv() {
|
|
63329
63330
|
return process.env.ULTRA11Y_LLM_MODEL?.trim() || DEFAULT_MODEL;
|
|
63330
63331
|
}
|
|
63332
|
+
var BudgetExceededError = class extends Error {
|
|
63333
|
+
constructor(message) {
|
|
63334
|
+
super(message);
|
|
63335
|
+
this.name = "BudgetExceededError";
|
|
63336
|
+
}
|
|
63337
|
+
};
|
|
63331
63338
|
function isProviderUnavailableError(error) {
|
|
63332
63339
|
const message = error instanceof Error ? error.message : String(error);
|
|
63333
63340
|
return /(?:api status|http)\s*(?:429|5\d\d)|\b429\b.*rate.?limit|rate.?limit|overload|service unavailable/i.test(message);
|
|
@@ -63480,6 +63487,7 @@ async function judgeAll(batches, opts) {
|
|
|
63480
63487
|
const verdicts = [];
|
|
63481
63488
|
const failures = [];
|
|
63482
63489
|
let done = 0;
|
|
63490
|
+
let total = batches.length;
|
|
63483
63491
|
const queue = [...batches];
|
|
63484
63492
|
const backend = opts.backend ?? judgeBatch;
|
|
63485
63493
|
const lanes = Math.max(1, opts.concurrency ?? CONCURRENCY);
|
|
@@ -63497,10 +63505,18 @@ async function judgeAll(batches, opts) {
|
|
|
63497
63505
|
verdicts.push(...checked.accepted);
|
|
63498
63506
|
if (checked.accepted.length) opts.onVerdicts?.(checked.accepted);
|
|
63499
63507
|
} catch (e) {
|
|
63508
|
+
if (e instanceof BudgetExceededError && b.items.length > 1 && opts.render) {
|
|
63509
|
+
const half = Math.ceil(b.items.length / 2);
|
|
63510
|
+
const parts2 = [b.items.slice(0, half), b.items.slice(half)];
|
|
63511
|
+
queue.unshift(...parts2.map((items) => ({ items, prompt: opts.render(items) })));
|
|
63512
|
+
total += parts2.length - 1;
|
|
63513
|
+
opts.onProgress?.(done, total);
|
|
63514
|
+
continue;
|
|
63515
|
+
}
|
|
63500
63516
|
failures.push(e instanceof Error ? e.message : String(e));
|
|
63501
63517
|
if (opts.abortOnError?.(e)) aborted = true;
|
|
63502
63518
|
}
|
|
63503
|
-
opts.onProgress?.(++done,
|
|
63519
|
+
opts.onProgress?.(++done, total);
|
|
63504
63520
|
}
|
|
63505
63521
|
})
|
|
63506
63522
|
);
|
|
@@ -63572,7 +63588,9 @@ function claudeBin() {
|
|
|
63572
63588
|
}
|
|
63573
63589
|
function batchSchema(items) {
|
|
63574
63590
|
const schema = structuredClone(VERDICT_TOOL.input_schema);
|
|
63575
|
-
const
|
|
63591
|
+
const verdicts = schema.properties.verdicts;
|
|
63592
|
+
verdicts.description = `Exactly one verdict for each of these ${items.length} worklist criteria. Do not omit criteria already reported non-conforming run-wide: page-level cells may still be open.`;
|
|
63593
|
+
const id = verdicts.items.properties.criteriaId;
|
|
63576
63594
|
id.enum = items.map((i2) => i2.criteriaId);
|
|
63577
63595
|
id.description = `The criterion id EXACTLY as given \u2014 the bare id (e.g. "1.2.1"), never the heading or the title beside it.`;
|
|
63578
63596
|
return schema;
|
|
@@ -63704,6 +63722,14 @@ async function runCli2(argv, prompt, opts, extract) {
|
|
|
63704
63722
|
}
|
|
63705
63723
|
if (env.is_error || env.subtype !== "success") {
|
|
63706
63724
|
lastError = `the CLI reported an error (${env.subtype ?? "unknown"}${env.api_error_status ? `, api status ${env.api_error_status}` : ""}).`;
|
|
63725
|
+
if (env.subtype === "error_max_budget_usd") {
|
|
63726
|
+
try {
|
|
63727
|
+
const salvaged = extract(env);
|
|
63728
|
+
if (Array.isArray(salvaged) ? salvaged.length > 0 : salvaged !== void 0) return salvaged;
|
|
63729
|
+
} catch {
|
|
63730
|
+
}
|
|
63731
|
+
throw new BudgetExceededError(`ultra11y judge: ${lastError}`);
|
|
63732
|
+
}
|
|
63707
63733
|
if (env.api_error_status && (env.api_error_status === 429 || env.api_error_status >= 500)) continue;
|
|
63708
63734
|
break;
|
|
63709
63735
|
}
|
|
@@ -74907,6 +74933,9 @@ async function cmdJudge(p) {
|
|
|
74907
74933
|
// runner with room.
|
|
74908
74934
|
concurrency: runner === "claude" || runner === "codex" ? cliConcurrency : void 0,
|
|
74909
74935
|
abortOnError: isProviderUnavailableError,
|
|
74936
|
+
// The seam that lets a budget abort be halved rather than lost: the same renderer
|
|
74937
|
+
// `batchWorklist` used, so a re-queued half is prompted exactly like a first-class batch.
|
|
74938
|
+
render: render2,
|
|
74910
74939
|
maxBudgetUsd: Number.isFinite(maxBudgetUsd) ? maxBudgetUsd : void 0,
|
|
74911
74940
|
effort,
|
|
74912
74941
|
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : void 0,
|
|
@@ -27,7 +27,7 @@ variables:
|
|
|
27
27
|
ULTRA11Y_LANG: en
|
|
28
28
|
# Exact by default: a CI report must say which engine produced it. The release script keeps
|
|
29
29
|
# this pin aligned with the package version; override only for a deliberate upgrade test.
|
|
30
|
-
ULTRA11Y_VERSION: '5.40.
|
|
30
|
+
ULTRA11Y_VERSION: '5.40.2'
|
|
31
31
|
# Batches of 8 amortise the prompt; each accepted verdict is checkpointed before the next
|
|
32
32
|
# batch, and later passes receive only the residue.
|
|
33
33
|
ULTRA11Y_GRAIN: batch
|
package/src/agent-cli.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { spawn } from "node:child_process";
|
|
|
22
22
|
import { existsSync } from "node:fs";
|
|
23
23
|
import { delimiter, join } from "node:path";
|
|
24
24
|
import type { AdjudicationItem } from "./adjudicate.js";
|
|
25
|
-
import { VERDICT_TOOL } from "./llm.js";
|
|
25
|
+
import { BudgetExceededError, VERDICT_TOOL } from "./llm.js";
|
|
26
26
|
import type { LlmOptions, RawVerdict } from "./llm.js";
|
|
27
27
|
import type { Lang } from "./types.js";
|
|
28
28
|
import { refuteSchema, refuteSystemPrompt, type VerifyItem } from "./verify.js";
|
|
@@ -131,9 +131,21 @@ export function claudeBin(): string[] {
|
|
|
131
131
|
* structured output against this schema, so the wrong shape cannot come back at all. */
|
|
132
132
|
export function batchSchema(items: AdjudicationItem[]): Record<string, unknown> {
|
|
133
133
|
const schema = structuredClone(VERDICT_TOOL.input_schema) as {
|
|
134
|
-
properties: {
|
|
134
|
+
properties: {
|
|
135
|
+
verdicts: {
|
|
136
|
+
description?: string;
|
|
137
|
+
items: { properties: { criteriaId: Record<string, unknown> } };
|
|
138
|
+
};
|
|
139
|
+
};
|
|
135
140
|
};
|
|
136
|
-
const
|
|
141
|
+
const verdicts = schema.properties.verdicts;
|
|
142
|
+
// Anthropic structured outputs support array minItems only at 0 or 1, not exact batch
|
|
143
|
+
// cardinality. Keep completeness in the shared worklist contract and repeat it beside the
|
|
144
|
+
// array the model is filling. A hard unsupported constraint could turn a useful 7/8 response
|
|
145
|
+
// into a provider error and lose all seven valid verdicts; validateBatchVerdicts deliberately
|
|
146
|
+
// preserves those seven and names the missing item for the next pass.
|
|
147
|
+
verdicts.description = `Exactly one verdict for each of these ${items.length} worklist criteria. Do not omit criteria already reported non-conforming run-wide: page-level cells may still be open.`;
|
|
148
|
+
const id = verdicts.items.properties.criteriaId;
|
|
137
149
|
id.enum = items.map((i) => i.criteriaId);
|
|
138
150
|
id.description = `The criterion id EXACTLY as given — the bare id (e.g. "1.2.1"), never the heading or the title beside it.`;
|
|
139
151
|
return schema as unknown as Record<string, unknown>;
|
|
@@ -338,6 +350,21 @@ async function runCli<T>(argv: string[], prompt: string, opts: LlmOptions, extra
|
|
|
338
350
|
}
|
|
339
351
|
if (env.is_error || env.subtype !== "success") {
|
|
340
352
|
lastError = `the CLI reported an error (${env.subtype ?? "unknown"}${env.api_error_status ? `, api status ${env.api_error_status}` : ""}).`;
|
|
353
|
+
// A BUDGET ABORT IS NOT AN EMPTY ANSWER. The ceiling can trip after the model has
|
|
354
|
+
// already produced its structured output, so LOOK before giving up — this envelope was
|
|
355
|
+
// paid for either way. `extract` is allowed to throw (usually it does: an abort with no
|
|
356
|
+
// final answer has nothing to parse), and that is the ordinary case, not an error.
|
|
357
|
+
if (env.subtype === "error_max_budget_usd") {
|
|
358
|
+
try {
|
|
359
|
+
const salvaged = extract(env);
|
|
360
|
+
if (Array.isArray(salvaged) ? salvaged.length > 0 : salvaged !== undefined) return salvaged;
|
|
361
|
+
} catch {
|
|
362
|
+
/* nothing usable in the envelope — fall through to the typed throw below */
|
|
363
|
+
}
|
|
364
|
+
// Not retried: attempt 2 is a fresh process with a fresh ceiling and the same work,
|
|
365
|
+
// so it buys the same abort at full price. Split it instead — see BudgetExceededError.
|
|
366
|
+
throw new BudgetExceededError(`ultra11y judge: ${lastError}`);
|
|
367
|
+
}
|
|
341
368
|
// Only a transport fault is worth another attempt; a refusal is not.
|
|
342
369
|
if (env.api_error_status && (env.api_error_status === 429 || env.api_error_status >= 500)) continue;
|
|
343
370
|
break;
|
package/src/cli.ts
CHANGED
|
@@ -3784,6 +3784,9 @@ async function cmdJudge(p: ParsedArgs): Promise<number> {
|
|
|
3784
3784
|
// runner with room.
|
|
3785
3785
|
concurrency: runner === "claude" || runner === "codex" ? cliConcurrency : undefined,
|
|
3786
3786
|
abortOnError: isProviderUnavailableError,
|
|
3787
|
+
// The seam that lets a budget abort be halved rather than lost: the same renderer
|
|
3788
|
+
// `batchWorklist` used, so a re-queued half is prompted exactly like a first-class batch.
|
|
3789
|
+
render,
|
|
3787
3790
|
maxBudgetUsd: Number.isFinite(maxBudgetUsd) ? maxBudgetUsd : undefined,
|
|
3788
3791
|
effort,
|
|
3789
3792
|
timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"wcagVersion": "2.2",
|
|
3
3
|
"source": "https://www.w3.org/TR/WCAG22/",
|
|
4
4
|
"criteriaSource": "https://github.com/w3c/wcag",
|
|
5
|
-
"provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-
|
|
5
|
+
"provenance": "Full WCAG 2.x SC universe (all levels incl. AAA, and the removed 4.1.1 Parsing) fetched from raw.githubusercontent.com/w3c/wcag@main on 2026-08-31 via `node scripts/build-standards.mjs --refresh-universe`. Classification: core-AA = ships in src/data/wcag.json (the shipped WCAG 2.2 AA core); out-of-core = WCAG AAA; removed = obsolete (4.1.1).",
|
|
6
6
|
"criteria": [
|
|
7
7
|
{
|
|
8
8
|
"id": "1.1.1",
|
package/src/llm.ts
CHANGED
|
@@ -120,6 +120,29 @@ export interface LlmOptions {
|
|
|
120
120
|
* without waiting out the real curve. */
|
|
121
121
|
backoffMs?: (attempt: number) => number;
|
|
122
122
|
onProgress?: (done: number, total: number) => void;
|
|
123
|
+
/** Re-render the prompt for an arbitrary slice of a worklist — the same function
|
|
124
|
+
* `batchWorklist` was given. Supplying it lets `judgeAll` HALVE a batch the provider
|
|
125
|
+
* aborted on `--max-budget-usd` and requeue both halves, because that ceiling is per
|
|
126
|
+
* invocation: the same criteria in two calls get two ceilings. Without it a budget abort
|
|
127
|
+
* costs the whole batch, which is the behaviour before this option existed. */
|
|
128
|
+
render?: (items: AdjudicationItem[]) => string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The CLI stopped because `--max-budget-usd` was reached.
|
|
132
|
+
*
|
|
133
|
+
* Typed, and not folded into the generic failure, because it is the ONE failure whose right
|
|
134
|
+
* answer is neither "retry" nor "give up": the ceiling is per INVOCATION, so the same work
|
|
135
|
+
* split across two invocations gets two ceilings. `judgeAll` halves the batch and requeues it,
|
|
136
|
+
* which turns a loss of eight criteria into a loss of one.
|
|
137
|
+
*
|
|
138
|
+
* Measured on a real run before this existed — 30 batch invocations, $38.90 spent, 12 verdicts
|
|
139
|
+
* kept: every batch that passed the ceiling was thrown away whole, having already been paid
|
|
140
|
+
* for. The cost is booked (`onCost` fires before this throws); only the work was lost. */
|
|
141
|
+
export class BudgetExceededError extends Error {
|
|
142
|
+
constructor(message: string) {
|
|
143
|
+
super(message);
|
|
144
|
+
this.name = "BudgetExceededError";
|
|
145
|
+
}
|
|
123
146
|
}
|
|
124
147
|
|
|
125
148
|
/** Provider saturation is shared state, not a property of one criterion. Transport backends
|
|
@@ -327,6 +350,9 @@ export async function judgeAll(
|
|
|
327
350
|
const verdicts: RawVerdict[] = [];
|
|
328
351
|
const failures: string[] = [];
|
|
329
352
|
let done = 0;
|
|
353
|
+
// Not `batches.length`: a budget abort splits a batch in two and requeues both, so the
|
|
354
|
+
// denominator the progress callback reports has to grow with the queue.
|
|
355
|
+
let total = batches.length;
|
|
330
356
|
const queue = [...batches];
|
|
331
357
|
const backend = opts.backend ?? judgeBatch;
|
|
332
358
|
const lanes = Math.max(1, opts.concurrency ?? CONCURRENCY);
|
|
@@ -346,10 +372,23 @@ export async function judgeAll(
|
|
|
346
372
|
// behind what it had already ruled on rather than nothing at all.
|
|
347
373
|
if (checked.accepted.length) opts.onVerdicts?.(checked.accepted);
|
|
348
374
|
} catch (e) {
|
|
375
|
+
// A BUDGET ABORT IS HALVED, NOT MOURNED. The ceiling is per invocation, so two calls
|
|
376
|
+
// get two ceilings: re-queueing the halves converts « eight criteria lost » into at
|
|
377
|
+
// worst « one criterion lost », and a batch that only just overran usually lands
|
|
378
|
+
// whole on the retry. Bounded by construction — halving 8 reaches 1 in three steps —
|
|
379
|
+
// and a single item that still overruns is a real failure, reported as one.
|
|
380
|
+
if (e instanceof BudgetExceededError && b.items.length > 1 && opts.render) {
|
|
381
|
+
const half = Math.ceil(b.items.length / 2);
|
|
382
|
+
const parts = [b.items.slice(0, half), b.items.slice(half)];
|
|
383
|
+
queue.unshift(...parts.map((items) => ({ items, prompt: opts.render!(items) })));
|
|
384
|
+
total += parts.length - 1;
|
|
385
|
+
opts.onProgress?.(done, total);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
349
388
|
failures.push(e instanceof Error ? e.message : String(e));
|
|
350
389
|
if (opts.abortOnError?.(e)) aborted = true;
|
|
351
390
|
}
|
|
352
|
-
opts.onProgress?.(++done,
|
|
391
|
+
opts.onProgress?.(++done, total);
|
|
353
392
|
}
|
|
354
393
|
}),
|
|
355
394
|
);
|
package/src/parse/html.ts
CHANGED
|
@@ -290,8 +290,12 @@ export function allIds(doc: Doc): { id: string; el: El }[] {
|
|
|
290
290
|
* its whole subtree. */
|
|
291
291
|
export function snippet(doc: Doc, el: El, max = 120): string {
|
|
292
292
|
const lineStart = doc.lineStarts[el.line - 1] ?? 0;
|
|
293
|
-
|
|
294
|
-
|
|
293
|
+
// `lineStarts` already indexes the next line. Searching for `\n` from this line's start for
|
|
294
|
+
// EVERY finding rescanned an entire minified one-line document each time: 8k dangling-label
|
|
295
|
+
// findings × a 650 kB line turned an otherwise-linear audit quadratic. The next recorded
|
|
296
|
+
// offset gives the same boundary in O(1); on the last/only line the document end is it.
|
|
297
|
+
const nextLineStart = doc.lineStarts[el.line];
|
|
298
|
+
const end = nextLineStart === undefined ? doc.source.length : Math.max(lineStart, nextLineStart - 1);
|
|
295
299
|
const from = Math.min(Math.max(el.start, lineStart), end);
|
|
296
300
|
// A JSX element usually opens on one line and carries its attributes on the next ones, so
|
|
297
301
|
// stopping at the line break showed "<a" and nothing else. Read to the end of the OPENING
|
package/src/standards/derive.ts
CHANGED
|
@@ -225,11 +225,18 @@ function judgmentGuard(r: PackCriterionResult, pc: PackCriterion): PackCriterion
|
|
|
225
225
|
* `judgmentGuard` then reopened as « à évaluer ». A criterion that already carries a verdict
|
|
226
226
|
* keeps it; this only ever touches `manual`.
|
|
227
227
|
*
|
|
228
|
-
*
|
|
228
|
+
* Four refusals:
|
|
229
229
|
* • `judgment` criteria. « All its rules were silent » does not answer a question about
|
|
230
230
|
* PERTINENCE, which is the whole reason `judgmentGuard` exists. Read from the PACK, not
|
|
231
231
|
* from the derived flag: that flag is only set when the guard actually intercepted a `C`.
|
|
232
232
|
* • `outOfScope`: no core success criterion to project from at all, which no measurement fixes.
|
|
233
|
+
* • a criterion whose pack DECLARES an automation contract without `completeBySilence`. That
|
|
234
|
+
* contract is the pack saying, test by test, that silence here does not prove conformity —
|
|
235
|
+
* and it outranks a measurement that only shows the rules ran. Written as `pc.automation &&`
|
|
236
|
+
* rather than `pc.automation?.` DELIBERATELY, and it is the same shape as `judgmentGuard`:
|
|
237
|
+
* a pack that declares NO contract has not refused anything, so optional chaining would
|
|
238
|
+
* read its silence as a refusal and kill this tier for every pack but RGAA — which is
|
|
239
|
+
* exactly what it did, without a test to notice.
|
|
233
240
|
* • an ADJUDICATED verdict never reaches here — the adjudication branch returns before this.
|
|
234
241
|
* An agent that ruled « undecidable » examined the criterion and said so; a rule that
|
|
235
242
|
* measured something narrower must not overturn it. That is what keeps RGAA 11.9 (« chaque
|
|
@@ -242,7 +249,7 @@ function measuredRescue(
|
|
|
242
249
|
ran: PageCoverage | undefined,
|
|
243
250
|
pageId: string | undefined,
|
|
244
251
|
): PackCriterionResult {
|
|
245
|
-
if (r.status !== "manual" || pc.judgment || r.outOfScope || pc.automation
|
|
252
|
+
if (r.status !== "manual" || pc.judgment || r.outOfScope || (pc.automation && pc.automation.completeBySilence !== true)) return r;
|
|
246
253
|
if (!criterionMeasuredOn(pc.appliesTo?.ruleIds, pc.wcag, cov, ran)) return r;
|
|
247
254
|
// The stale explanations go with the stale status: `scopedOut` and `judgment` say why the
|
|
248
255
|
// criterion STAYED open, and it no longer is.
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Single source of truth for shared types + the embedded version. sync-version.mjs
|
|
2
2
|
// rewrites VERSION at release time (kept in lockstep with package.json + SKILL.md).
|
|
3
|
-
export const VERSION = "5.40.
|
|
3
|
+
export const VERSION = "5.40.2";
|
|
4
4
|
export const SCHEMA_VERSION = 2;
|
|
5
5
|
|
|
6
6
|
// Lang = the UI FRAME's language — the `L` localization tables each module (report,
|
package/src/verdict-rules.ts
CHANGED
|
@@ -26,6 +26,13 @@ export const VERDICT_KINDS = `Rule it (the apply gate is FAIL-CLOSED — a verdi
|
|
|
26
26
|
- \`NA\` (not applicable) — REQUIRES \`justification\`, AND \`citations[]\` whenever evidence WAS presented, to say which of those items fall outside the criterion's scope.
|
|
27
27
|
- \`manual\` (still undecidable) — REQUIRES \`reason\`: \`needs-rendered-dom\` (only a rendered DOM can decide it, and no capture in this run carries its subject) or \`undecidable\` (the evidence cannot settle it either way).`;
|
|
28
28
|
|
|
29
|
+
/** The worklist, not the run-level tally, is the source of truth for what remains to rule.
|
|
30
|
+
*
|
|
31
|
+
* Measured on the realworld fixture: the agent left 17 items blank because their decisive
|
|
32
|
+
* failures already made the run-wide grid NC. Those items were deliberately present because
|
|
33
|
+
* other page cells were still open; the run finished 106/106 globally and failed every page. */
|
|
34
|
+
export const WORKLIST_RULE = `THE WORKLIST IS THE RESIDUAL. Return exactly one verdict for EVERY criterion presented — never omit an item because the engine already reported a run-wide \`NC\`. The strict page gate deliberately puts such a criterion back on the worklist when page-level cells remain open. It is not surplus and it is not already resolved for this task: inspect the complete evidence and rule it. If the evidence cannot settle it, return \`manual\` with a reason; never leave its verdict blank.`;
|
|
35
|
+
|
|
29
36
|
/** An absence is observed somewhere. Measured: 12.1 and 12.5 lost to this on one run. */
|
|
30
37
|
export const ABSENCE_RULE = `AN NC SHAPED LIKE AN ABSENCE IS STILL ANCHORED. « No second navigation system », « no search engine », « no error message suggests the expected format » — an absence is OBSERVED somewhere: cite the element and the page you observed it on. And when the criterion's subject exists nowhere in the audited scope, the verdict is \`NA\` with its justification, never \`NC\`.
|
|
31
38
|
|
|
@@ -49,7 +56,7 @@ export const SCOPE_RULE = `Rule ONLY on the criteria presented. Never introduce
|
|
|
49
56
|
export const CONFORMITY_RULE = `A \`C\` WILL BE ATTACKED, exactly as an \`NC\` is. Every conformity you record goes into an adversarial worklist where a second reader opens your citations and asks whether they ESTABLISH the criterion or merely show that its subject exists — a present \`alt\` is not a relevant \`alt\`, a present \`<title>\` is not a title that describes the page. Cite the evidence that answers the criterion's own question, and when the evidence only proves presence, the honest verdict is \`manual\`.`;
|
|
50
57
|
|
|
51
58
|
/** The clauses in the order every surface states them, after the verdict kinds. */
|
|
52
|
-
const TAIL = [ABSENCE_RULE, CAPTURE_RULE, NEVER_GUESS_RULE, SCOPE_RULE, CONFORMITY_RULE];
|
|
59
|
+
const TAIL = [WORKLIST_RULE, ABSENCE_RULE, CAPTURE_RULE, NEVER_GUESS_RULE, SCOPE_RULE, CONFORMITY_RULE];
|
|
53
60
|
|
|
54
61
|
/** Render the rules as a numbered Markdown list starting at `startAt`.
|
|
55
62
|
*
|