ultra11y 5.3.4 → 5.4.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/.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 +1700 -1276
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +1700 -1276
- package/skills/ultra11y/SKILL.md +2 -2
- package/skills/ultra11y/references/ci.md +46 -4
- package/skills/ultra11y/references/judgment.md +25 -12
- package/skills/ultra11y/references/pages.md +13 -4
- package/skills/ultra11y/scripts/ultra11y.mjs +1700 -1276
- package/src/adjudicate.ts +159 -37
- package/src/audit.ts +163 -2
- package/src/cli.ts +204 -18
- package/src/ledger.ts +250 -0
- package/src/orchestrate-templates.ts +2 -2
- package/src/report.ts +24 -6
- package/src/rules/rendered.ts +39 -1
- package/src/types.ts +10 -3
package/src/adjudicate.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// C/NA, no ungroundable NC, no reasonless manual, full coverage of the residual set. The
|
|
11
11
|
// decisions are the AGENT's, statically, gated — not a deferral to a human.
|
|
12
12
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { join } from "node:path";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
14
|
import type { AuditResult, Automatability, Finding, Lang, PackCriterionAdjudication, ResidualRisk, Severity, Status } from "./types.js";
|
|
15
15
|
import { SCHEMA_VERSION } from "./types.js";
|
|
16
16
|
import { discover } from "./discover.js";
|
|
@@ -20,7 +20,7 @@ import { type Doc, type El, elementsByTag, attr, textContent, ancestors, snippet
|
|
|
20
20
|
import { parseInlineStyle } from "./color.js";
|
|
21
21
|
import { ADJUDICATION } from "./adjudication-data.js";
|
|
22
22
|
import { scTitle, getSC, hasSC, techniquesFor, allSC, guidelineTitle } from "./wcag.js";
|
|
23
|
-
import {
|
|
23
|
+
import { groundFinding, type GroundingSummary } from "./grounding.js";
|
|
24
24
|
import {
|
|
25
25
|
type StandardId,
|
|
26
26
|
CORE,
|
|
@@ -331,7 +331,13 @@ function docsForAudit(audit: AuditResult, cwd?: string): Doc[] {
|
|
|
331
331
|
const docs: Doc[] = [];
|
|
332
332
|
for (const f of files) {
|
|
333
333
|
try {
|
|
334
|
-
|
|
334
|
+
// `resolve`, not `join`: a cwd resolves RELATIVE paths and must leave an absolute one
|
|
335
|
+
// alone. `join("/repo", "/tmp/x/page.html")` produced `/repo/tmp/x/page.html`, which is
|
|
336
|
+
// unreadable — and the catch below swallows it, so every criterion silently arrived with
|
|
337
|
+
// ZERO evidence. The gate then refused each C verdict for "no evidence harvested",
|
|
338
|
+
// blaming the adjudicator for a path bug. Grounding has always used `resolve` for the
|
|
339
|
+
// same reason; these two must agree, since one harvests the anchors the other checks.
|
|
340
|
+
docs.push(parseSource(readText(cwd ? resolve(cwd, f) : f), f));
|
|
335
341
|
} catch {
|
|
336
342
|
/* unreadable — skip, mirrors runAudit */
|
|
337
343
|
}
|
|
@@ -413,6 +419,13 @@ export interface ApplyAdjudicationResult {
|
|
|
413
419
|
issues: string[];
|
|
414
420
|
applied: number;
|
|
415
421
|
stillManual: number;
|
|
422
|
+
/** Criteria whose verdict was REFUSED by the gate and therefore not applied. They stay
|
|
423
|
+
* « to assess », each carrying the refusal as its residual reason. Always 0 in strict mode,
|
|
424
|
+
* where a single refusal discards the whole fold. */
|
|
425
|
+
rejected: number;
|
|
426
|
+
/** The ids behind `rejected`, in file order — so a caller can name them without re-parsing
|
|
427
|
+
* `issues` (one criterion can raise several). */
|
|
428
|
+
rejectedCriteria: string[];
|
|
416
429
|
grounding: GroundingSummary;
|
|
417
430
|
}
|
|
418
431
|
|
|
@@ -504,11 +517,51 @@ function normativeRefResolves(ref: string | undefined, standard: StandardId, ite
|
|
|
504
517
|
return itemCriterionId === undefined || critId === itemCriterionId;
|
|
505
518
|
}
|
|
506
519
|
|
|
507
|
-
/** Fold an adjudication file back into the audit.
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
|
|
520
|
+
/** Fold an adjudication file back into the audit. Returns a NEW AuditResult with the decided
|
|
521
|
+
* statuses, agent findings, recomputed conformancePct, a shrunk residual set, and the
|
|
522
|
+
* `adjudicated` marker.
|
|
523
|
+
*
|
|
524
|
+
* FAIL-CLOSED PER VERDICT, which is not the same thing as fail-closed per FILE. Every check
|
|
525
|
+
* below is unchanged and no refused verdict is ever applied — but a refusal now costs its own
|
|
526
|
+
* criterion and nothing else. The all-or-nothing fold was measured to be worse than useless:
|
|
527
|
+
* a CI run that filled 95 of 96 verdicts correctly had all 96 discarded, so the audit paid a
|
|
528
|
+
* full adjudication and published « to assess » across the board. Since the point of the gate
|
|
529
|
+
* is to keep an unproven verdict out of the report — not to punish the ones that proved
|
|
530
|
+
* themselves — a refused criterion simply stays « to assess », carrying the refusal as its
|
|
531
|
+
* residual reason so the next run knows what to fix.
|
|
532
|
+
*
|
|
533
|
+
* `strict: true` restores the old file-level behaviour for a caller that genuinely wants
|
|
534
|
+
* all-or-nothing (a conformance deliverable signed off in one pass). */
|
|
535
|
+
export function applyAdjudication(
|
|
536
|
+
audit: AuditResult,
|
|
537
|
+
adj: AdjudicationFile,
|
|
538
|
+
opts: {
|
|
539
|
+
cwd?: string;
|
|
540
|
+
strict?: boolean;
|
|
541
|
+
/** Criteria the caller KNOWS this adjudication does not cover, each with the reason to
|
|
542
|
+
* record. A ledger replay supplies them (« stale », « never adjudicated ») so an absence
|
|
543
|
+
* it fully expected is not reported as a coverage violation — while the criterion still
|
|
544
|
+
* stays to assess, carrying that reason instead of a blank cell. */
|
|
545
|
+
residualReasons?: Record<string, string>;
|
|
546
|
+
} = {},
|
|
547
|
+
): ApplyAdjudicationResult {
|
|
511
548
|
const issues: string[] = [];
|
|
549
|
+
const expected = opts.residualReasons ?? {};
|
|
550
|
+
// Per-criterion attribution. The gate used to collect one flat list, which is why it could
|
|
551
|
+
// only ever reject the whole file: an issue did not know which verdict it condemned.
|
|
552
|
+
const itemIssues = new Map<string, string[]>();
|
|
553
|
+
// Criteria that will NOT fold and must keep `manual` with a reason — the refused ones plus
|
|
554
|
+
// the ones the caller declared uncovered.
|
|
555
|
+
const notFolded = new Set<string>();
|
|
556
|
+
const blame = (criteriaId: string, issue: string) => {
|
|
557
|
+
issues.push(issue);
|
|
558
|
+
notFolded.add(criteriaId);
|
|
559
|
+
const list = itemIssues.get(criteriaId);
|
|
560
|
+
if (list) list.push(issue);
|
|
561
|
+
else itemIssues.set(criteriaId, [issue]);
|
|
562
|
+
};
|
|
563
|
+
/** An open criterion this adjudication deliberately does not carry. Not a gate violation. */
|
|
564
|
+
const uncovered = (criteriaId: string) => notFolded.add(criteriaId);
|
|
512
565
|
// Spelling first, decisions second: "na" is NA, and rejecting the run over the case of a
|
|
513
566
|
// verdict taught the caller nothing about accessibility. Everything below stays fail-closed.
|
|
514
567
|
canonicalizeAdjudication(adj);
|
|
@@ -523,12 +576,16 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
|
|
|
523
576
|
for (const pc of derivePackResults(audit, adj.standard)) {
|
|
524
577
|
if (pc.status !== "manual") continue;
|
|
525
578
|
open.add(pc.id);
|
|
526
|
-
if (
|
|
579
|
+
if (byId.has(pc.id)) continue;
|
|
580
|
+
if (Object.hasOwn(expected, pc.id)) uncovered(pc.id);
|
|
581
|
+
else blame(pc.id, `criterion ${pc.id}: missing from the adjudication (coverage gap)`);
|
|
527
582
|
}
|
|
528
583
|
} else {
|
|
529
584
|
for (const r of audit.residualRisks) {
|
|
530
585
|
open.add(r.criteriaId);
|
|
531
|
-
if (
|
|
586
|
+
if (byId.has(r.criteriaId)) continue;
|
|
587
|
+
if (Object.hasOwn(expected, r.criteriaId)) uncovered(r.criteriaId);
|
|
588
|
+
else blame(r.criteriaId, `criterion ${r.criteriaId}: missing from the adjudication (coverage gap)`);
|
|
532
589
|
}
|
|
533
590
|
}
|
|
534
591
|
|
|
@@ -539,18 +596,25 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
|
|
|
539
596
|
// citation, no normativeRef. Adjudication may only ever decide what the engine left open.
|
|
540
597
|
for (const it of adj.items) {
|
|
541
598
|
if (!open.has(it.criteriaId)) {
|
|
542
|
-
|
|
599
|
+
blame(it.criteriaId, `criterion ${it.criteriaId}: not open for adjudication — the engine already decided it, or it is not part of ${adj.standard}`);
|
|
543
600
|
}
|
|
544
601
|
}
|
|
545
602
|
|
|
546
|
-
// Per-item fail-closed validation.
|
|
547
|
-
|
|
603
|
+
// Per-item fail-closed validation. Grounding inputs are collected PER ITEM (not into one
|
|
604
|
+
// flat list) for the same reason as `blame`: a failed citation has to condemn its own
|
|
605
|
+
// criterion and no other.
|
|
606
|
+
const groundInputs = new Map<string, { file: string; line: number; selector?: string; snippet?: string }[]>();
|
|
607
|
+
const toGround = (criteriaId: string, g: { file: string; line: number; selector?: string; snippet?: string }) => {
|
|
608
|
+
const list = groundInputs.get(criteriaId);
|
|
609
|
+
if (list) list.push(g);
|
|
610
|
+
else groundInputs.set(criteriaId, [g]);
|
|
611
|
+
};
|
|
548
612
|
for (const it of adj.items) {
|
|
549
613
|
const v = it.verdict;
|
|
550
614
|
if (v === null) {
|
|
551
|
-
|
|
615
|
+
blame(it.criteriaId, `criterion ${it.criteriaId}: unadjudicated (verdict is null)`);
|
|
552
616
|
} else if (v === "C" || v === "NA") {
|
|
553
|
-
if (!it.justification || !it.justification.trim())
|
|
617
|
+
if (!it.justification || !it.justification.trim()) blame(it.criteriaId, `criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
|
|
554
618
|
// A clearing verdict is gated exactly like an accusing one. Before this, the only
|
|
555
619
|
// check was "the justification is a non-empty string", so `"x"` cleared a criterion —
|
|
556
620
|
// and a model answering C to everything published a conformance nobody had assessed.
|
|
@@ -561,12 +625,14 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
|
|
|
561
625
|
// concerned"); `C` is not — it must stay `manual` and be ruled on against the
|
|
562
626
|
// criterion's own tests, from a rendered capture or by hand.
|
|
563
627
|
if (v === "C") {
|
|
564
|
-
|
|
628
|
+
blame(
|
|
629
|
+
it.criteriaId,
|
|
565
630
|
`criterion ${it.criteriaId}: a C verdict needs evidence to cite, and none was harvested for this criterion — record "manual" (reason "undecidable"), or "NA" if nothing in scope is concerned`,
|
|
566
631
|
);
|
|
567
632
|
}
|
|
568
633
|
} else if (cites.length === 0) {
|
|
569
|
-
|
|
634
|
+
blame(
|
|
635
|
+
it.criteriaId,
|
|
570
636
|
`criterion ${it.criteriaId}: a ${v} verdict must cite at least one of the ${it.evidence.length} evidence item(s) it was shown (citations: [{file, line, …}])`,
|
|
571
637
|
);
|
|
572
638
|
} else {
|
|
@@ -576,50 +642,75 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
|
|
|
576
642
|
const anchors = new Set(it.evidence.map((e) => `${e.file}:${e.line}`));
|
|
577
643
|
for (const c of cites) {
|
|
578
644
|
if (!anchors.has(`${c.file}:${c.line}`)) {
|
|
579
|
-
|
|
645
|
+
blame(it.criteriaId, `criterion ${it.criteriaId}: citation ${c.file}:${c.line} is not among this criterion's harvested evidence (fabricated?)`);
|
|
580
646
|
}
|
|
581
|
-
|
|
647
|
+
toGround(it.criteriaId, { file: c.file, line: c.line, selector: c.selector, snippet: c.snippet });
|
|
582
648
|
}
|
|
583
649
|
}
|
|
584
650
|
} else if (v === "NC") {
|
|
585
|
-
if (!it.findings || it.findings.length === 0)
|
|
651
|
+
if (!it.findings || it.findings.length === 0) blame(it.criteriaId, `criterion ${it.criteriaId}: an NC verdict requires at least one groundable finding`);
|
|
586
652
|
for (const f of it.findings ?? []) {
|
|
587
653
|
// FAIL-CLOSED: every NC finding must cite a precise, resolvable test of the active
|
|
588
654
|
// standard. A good practice with no normative test is a recommendation, not an NC.
|
|
589
655
|
if (!f.normativeRef || !f.normativeRef.trim()) {
|
|
590
|
-
|
|
656
|
+
blame(it.criteriaId, `criterion ${it.criteriaId}: an NC finding requires a normativeRef citing the failed test of the active standard`);
|
|
591
657
|
} else if (!normativeRefResolves(f.normativeRef, adj.standard, isCore(adj.standard) ? undefined : it.criteriaId)) {
|
|
592
|
-
|
|
658
|
+
blame(
|
|
659
|
+
it.criteriaId,
|
|
593
660
|
isCore(adj.standard)
|
|
594
661
|
? `criterion ${it.criteriaId}: normativeRef "${f.normativeRef}" does not resolve to a test of ${adj.standard} (fabricated?)`
|
|
595
662
|
: `criterion ${it.criteriaId}: normativeRef "${f.normativeRef}" is not a test of ${adj.standard} ${it.criteriaId} — cite one of its own tests (e.g. "${it.criteriaId}.1"); a WCAG id looks alike but denotes an unrelated test`,
|
|
596
663
|
);
|
|
597
664
|
}
|
|
598
|
-
|
|
665
|
+
toGround(it.criteriaId, { file: f.file, line: f.line, selector: f.selector, snippet: f.snippet });
|
|
599
666
|
}
|
|
600
667
|
} else if (v === "manual") {
|
|
601
668
|
if (!it.reason || !MANUAL_REASONS.has(it.reason))
|
|
602
|
-
|
|
669
|
+
blame(it.criteriaId, `criterion ${it.criteriaId}: a manual verdict requires reason ∈ {${MANUAL_REASON_VALUES.join(", ")}}`);
|
|
603
670
|
} else {
|
|
604
671
|
// Name the vocabulary in the rejection. The caller is usually a model that has just
|
|
605
672
|
// spent a whole worklist filling this file; "unknown verdict" alone told it nothing
|
|
606
673
|
// about how to be right on the retry.
|
|
607
|
-
|
|
674
|
+
blame(it.criteriaId, `criterion ${it.criteriaId}: unknown verdict "${String(v)}" — expected one of ${VERDICTS.join(" | ")}`);
|
|
608
675
|
}
|
|
609
676
|
// Recommendations are independent of the verdict (a C criterion may still carry a good
|
|
610
677
|
// practice) and are grounded exactly like an NC finding — no normativeRef required, as
|
|
611
678
|
// a recommendation has no normative test by definition.
|
|
612
|
-
for (const rec of it.recommendations ?? [])
|
|
679
|
+
for (const rec of it.recommendations ?? []) toGround(it.criteriaId, { file: rec.file, line: rec.line, selector: rec.selector, snippet: rec.snippet });
|
|
613
680
|
}
|
|
614
681
|
|
|
615
682
|
// Content-level grounding of every agent NC finding, every C/NA citation, and every
|
|
616
|
-
// recommendation — the same check, whichever direction the verdict points.
|
|
617
|
-
|
|
618
|
-
|
|
683
|
+
// recommendation — the same check, whichever direction the verdict points. Walked per
|
|
684
|
+
// criterion so a failure is attributable; the aggregate summary is identical to what the
|
|
685
|
+
// flat `groundItems` produced.
|
|
686
|
+
const grounding: GroundingSummary = { grounded: 0, moved: 0, failed: 0, issues: [] };
|
|
687
|
+
for (const [criteriaId, inputs] of groundInputs) {
|
|
688
|
+
for (const input of inputs) {
|
|
689
|
+
const r = groundFinding(input, { cwd: opts.cwd });
|
|
690
|
+
if (r.ok) {
|
|
691
|
+
grounding.grounded++;
|
|
692
|
+
if (r.moved) grounding.moved++;
|
|
693
|
+
} else {
|
|
694
|
+
grounding.failed++;
|
|
695
|
+
if (r.issue) {
|
|
696
|
+
grounding.issues.push(r.issue);
|
|
697
|
+
blame(criteriaId, r.issue);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
619
702
|
|
|
620
|
-
|
|
621
|
-
|
|
703
|
+
// STRICT: the historical file-level fold. One refusal, nothing lands.
|
|
704
|
+
if (opts.strict && issues.length) {
|
|
705
|
+
return { ok: false, audit, issues, applied: 0, stillManual: 0, rejected: 0, rejectedCriteria: [], grounding };
|
|
622
706
|
}
|
|
707
|
+
// PARTIAL: a refused criterion is dropped, the rest folds. `rejectedCriteria` keeps file
|
|
708
|
+
// order and de-duplicates, since one criterion can raise several issues. It counts only the
|
|
709
|
+
// criteria the gate REFUSED — an uncovered one the caller already declared is not a refusal.
|
|
710
|
+
const rejectedCriteria = adj.items.map((it) => it.criteriaId).filter((id) => itemIssues.has(id));
|
|
711
|
+
for (const id of itemIssues.keys()) if (!rejectedCriteria.includes(id)) rejectedCriteria.push(id);
|
|
712
|
+
const rejectedSet = notFolded;
|
|
713
|
+
const rejectedWhy = (id: string) => expected[id] ?? residualRejectedReason(itemIssues.get(id)?.[0] ?? "refused by the gate");
|
|
623
714
|
|
|
624
715
|
// Apply: clone the audit, update the decided criteria + append agent findings.
|
|
625
716
|
const next: AuditResult = structuredClone(audit);
|
|
@@ -636,6 +727,20 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
|
|
|
636
727
|
if (packMode) {
|
|
637
728
|
const decided: PackCriterionAdjudication[] = [];
|
|
638
729
|
for (const it of adj.items) {
|
|
730
|
+
// A refused verdict does not land. It is recorded as still-`manual` carrying the
|
|
731
|
+
// refusal, so the page grid and the report say WHY this criterion is still to assess
|
|
732
|
+
// instead of leaving a blank cell.
|
|
733
|
+
if (rejectedSet.has(it.criteriaId)) {
|
|
734
|
+
if (!open.has(it.criteriaId)) continue; // surplus item: not ours to record at all
|
|
735
|
+
decided.push({
|
|
736
|
+
id: it.criteriaId,
|
|
737
|
+
status: "manual",
|
|
738
|
+
reason: "undecidable",
|
|
739
|
+
justification: rejectedWhy(it.criteriaId),
|
|
740
|
+
findings: [],
|
|
741
|
+
});
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
639
744
|
if (it.verdict === "manual") {
|
|
640
745
|
stillManual++;
|
|
641
746
|
decided.push({
|
|
@@ -660,17 +765,25 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
|
|
|
660
765
|
decidedBy: "agent",
|
|
661
766
|
});
|
|
662
767
|
}
|
|
768
|
+
// An OPEN criterion the file never mentioned — a coverage gap, or one the caller declared
|
|
769
|
+
// uncovered — also has to say why it is still to assess; the loop above only walks the
|
|
770
|
+
// items that exist.
|
|
771
|
+
for (const id of notFolded) {
|
|
772
|
+
if (byId.has(id) || !open.has(id)) continue;
|
|
773
|
+
decided.push({ id, status: "manual", reason: "undecidable", justification: rejectedWhy(id), findings: [] });
|
|
774
|
+
}
|
|
663
775
|
next.packAdjudication = { standard: adj.standard, criteria: decided };
|
|
664
776
|
// The agent's findings still join the flat list so grounding, `check` and the reports can
|
|
665
777
|
// resolve them — but they never touch a WCAG criterion's status.
|
|
666
778
|
next.findings = [...next.findings, ...newFindings];
|
|
667
|
-
next.adjudicated = { date: adj.auditDate, applied, stillManual };
|
|
668
|
-
return { ok:
|
|
779
|
+
next.adjudicated = { date: adj.auditDate, applied, stillManual, ...(rejectedCriteria.length ? { rejected: rejectedCriteria.length } : {}) };
|
|
780
|
+
return { ok: issues.length === 0, audit: next, issues, applied, stillManual, rejected: rejectedCriteria.length, rejectedCriteria, grounding };
|
|
669
781
|
}
|
|
670
782
|
|
|
671
783
|
for (const it of adj.items) {
|
|
672
784
|
const c = critById.get(it.criteriaId);
|
|
673
785
|
if (!c) continue; // an item for a non-residual criterion — ignore (coverage already gated)
|
|
786
|
+
if (rejectedSet.has(it.criteriaId)) continue; // refused: the criterion keeps its `manual` status
|
|
674
787
|
if (it.verdict === "manual") {
|
|
675
788
|
c.status = "manual";
|
|
676
789
|
c.decidedBy = "agent";
|
|
@@ -696,7 +809,7 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
|
|
|
696
809
|
// gets its recommendations, and an NC item's reset `c.findings` keeps them appended last.
|
|
697
810
|
for (const it of adj.items) {
|
|
698
811
|
const c = critById.get(it.criteriaId);
|
|
699
|
-
if (!c) continue;
|
|
812
|
+
if (!c || rejectedSet.has(it.criteriaId)) continue;
|
|
700
813
|
for (const rec of it.recommendations ?? []) {
|
|
701
814
|
const f = agentFinding(it.criteriaId, rec, true);
|
|
702
815
|
c.findings.push(f);
|
|
@@ -705,11 +818,16 @@ export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opt
|
|
|
705
818
|
}
|
|
706
819
|
|
|
707
820
|
next.findings = [...next.findings, ...newFindings];
|
|
708
|
-
// Residual set now holds
|
|
709
|
-
|
|
821
|
+
// Residual set now holds every criterion still to assess: the ones ruled `manual` by the
|
|
822
|
+
// adjudication, AND the ones whose verdict the gate refused. A refused criterion keeps its
|
|
823
|
+
// place in the residual set with the refusal as its reason — dropping it would report it as
|
|
824
|
+
// decided, and blanking the reason is exactly the empty cell this release is removing.
|
|
825
|
+
next.residualRisks = next.residualRisks
|
|
826
|
+
.filter((r) => byId.get(r.criteriaId)?.verdict === "manual" || rejectedSet.has(r.criteriaId))
|
|
827
|
+
.map((r) => (rejectedSet.has(r.criteriaId) ? { ...r, reason: rejectedWhy(r.criteriaId) } : r));
|
|
710
828
|
recomputeTallies(next);
|
|
711
|
-
next.adjudicated = { date: adj.auditDate, applied, stillManual };
|
|
712
|
-
return { ok:
|
|
829
|
+
next.adjudicated = { date: adj.auditDate, applied, stillManual, ...(rejectedCriteria.length ? { rejected: rejectedCriteria.length } : {}) };
|
|
830
|
+
return { ok: issues.length === 0, audit: next, issues, applied, stillManual, rejected: rejectedCriteria.length, rejectedCriteria, grounding };
|
|
713
831
|
}
|
|
714
832
|
|
|
715
833
|
function agentFinding(criteriaId: string, f: AgentFinding, advisory = false): Finding {
|
|
@@ -750,6 +868,10 @@ function recomputeTallies(a: AuditResult): void {
|
|
|
750
868
|
|
|
751
869
|
const residualScanReason = () => "Rendering criterion — decide on the rendered DOM (`scan`).";
|
|
752
870
|
const residualUndecidableReason = () => "Left as an explicit residual risk (not decidable from the available evidence).";
|
|
871
|
+
/** Why a criterion is still to assess after a PARTIAL fold: its verdict was refused. Carries
|
|
872
|
+
* the first refusal verbatim, so the reader (and the next adjudication pass) sees the actual
|
|
873
|
+
* contract violation rather than a bare « to assess ». */
|
|
874
|
+
const residualRejectedReason = (why: string) => `Adjudication refused by the gate — ${why.replace(/^criterion \S+: /, "")}. Re-adjudicate this criterion.`;
|
|
753
875
|
|
|
754
876
|
// ---- worklist file rendering ----
|
|
755
877
|
const T = {
|
package/src/audit.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { attachSignals, isSnapshotDom, snapshotPageId } from "./snapshot.js";
|
|
|
12
12
|
import { attr, elementsByTag, type Doc, type CaptureProvenance } from "./parse/html.js";
|
|
13
13
|
import { CAPTURES_DIR, computeCaptureCoverage, enrichCaptureOrigins, isUnderDir, readCaptureDir, capturesForSources } from "./capture.js";
|
|
14
14
|
import { isFullDocument } from "./rules/rule.js";
|
|
15
|
+
import { renderedTestedScs } from "./rules/rendered.js";
|
|
15
16
|
import { runRules } from "./rules/registry.js";
|
|
16
17
|
import { runCrossRules } from "./rules/cross-registry.js";
|
|
17
18
|
import { listPacks } from "./standards/registry.js";
|
|
@@ -64,12 +65,141 @@ const APPLICABLE: Record<string, (d: Doc) => boolean> = {
|
|
|
64
65
|
"3.1.1": (d) => isFullDocument(d), // Language of Page — html-lang-missing / lang-invalid
|
|
65
66
|
};
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
// ---- SUBJECT MATTER: what makes a NON-static criterion applicable at all -----------------
|
|
69
|
+
//
|
|
70
|
+
// A judgment or rendering criterion used to have exactly two outcomes: `NC` when a rule fired,
|
|
71
|
+
// `manual` otherwise. So a repository containing no audio and no video still reported all five
|
|
72
|
+
// time-based-media criteria as « to assess » — and under RGAA, whose theme 4 projects from
|
|
73
|
+
// them, that is 13 criteria a reader has to work through to discover there was never anything
|
|
74
|
+
// to look at. Measured on a real 300-file audit: 96 of 106 criteria « to assess », whole themes
|
|
75
|
+
// among them applicable to nothing in scope.
|
|
76
|
+
//
|
|
77
|
+
// « Not applicable » is a real, normative verdict, and the engine can prove it for a criterion
|
|
78
|
+
// whose SUBJECT MATTER is a thing you can look for in the source. So: no media element in the
|
|
79
|
+
// whole scope ⇒ the media criteria are NA, with a justification naming the observation.
|
|
80
|
+
//
|
|
81
|
+
// Three rules keep this honest, because a wrong NA is far worse than an honest « to assess » —
|
|
82
|
+
// it is a non-conformity hidden inside a report someone signs:
|
|
83
|
+
//
|
|
84
|
+
// 1. OVER-INCLUSION IS THE SAFE DIRECTION. A predicate answers "could this criterion
|
|
85
|
+
// possibly apply here?", so it must say YES on anything ambiguous. `<object>`/`<embed>`/
|
|
86
|
+
// `<iframe>` are usually not media, and they are all listed anyway: their presence merely
|
|
87
|
+
// keeps a criterion open, which costs nothing but a line in the worklist.
|
|
88
|
+
// 2. APPLICABILITY IS OR-FOLDED ACROSS THE WHOLE SCOPE (see `foldDoc`). One document with a
|
|
89
|
+
// `<video>` keeps the media criteria open for the entire audit. NA means "nothing,
|
|
90
|
+
// anywhere in what was audited" — never "nothing in this file".
|
|
91
|
+
// 3. THE VERDICT IS SCOPE-BOUND AND SAYS SO. The justification states what was searched and
|
|
92
|
+
// not found, so it is falsifiable by a reader. A scope that cannot see rendered output
|
|
93
|
+
// (an opaque component library) is already flagged on the audit itself, and `check`'s
|
|
94
|
+
// unjustified-NA gate still applies.
|
|
95
|
+
const SUBJECT_MATTER: Record<string, (d: Doc) => boolean> = {
|
|
96
|
+
// ---- Time-based media (WCAG 1.2.x → RGAA theme 4) ----
|
|
97
|
+
// Captions, transcripts, audio description and sign language all presuppose a media element.
|
|
98
|
+
// `track` counts on its own: a stray <track> means media is being assembled somewhere.
|
|
99
|
+
"1.2.1": hasMedia,
|
|
100
|
+
"1.2.2": hasMedia,
|
|
101
|
+
"1.2.3": hasMedia,
|
|
102
|
+
"1.2.4": hasMedia,
|
|
103
|
+
"1.2.5": hasMedia,
|
|
104
|
+
// ---- Motion actuation (WCAG 2.5.4) ----
|
|
105
|
+
// Applicable only where device motion drives the interface, which is a source-visible API.
|
|
106
|
+
"2.5.4": hasMotionApi,
|
|
107
|
+
// ---- Forms (WCAG 3.3.x) ----
|
|
108
|
+
// Error identification, labels and instructions, error suggestion, error prevention,
|
|
109
|
+
// redundant entry and accessible authentication all presuppose user input. 3.3.4 and 3.3.8
|
|
110
|
+
// are narrower still (legal/financial transactions, authentication), so "any form control"
|
|
111
|
+
// is the deliberately over-inclusive predicate for them.
|
|
112
|
+
"3.3.1": hasFormControl,
|
|
113
|
+
"3.3.2": hasFormControl,
|
|
114
|
+
"3.3.3": hasFormControl,
|
|
115
|
+
"3.3.4": hasFormControl,
|
|
116
|
+
"3.3.7": hasFormControl,
|
|
117
|
+
"3.3.8": hasFormControl,
|
|
118
|
+
// Identify Input Purpose — about autocomplete on fields collecting user data.
|
|
119
|
+
"1.3.5": hasFormControl,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Media, and everything that could be carrying media. Over-inclusive on purpose (rule 1). */
|
|
123
|
+
function hasMedia(d: Doc): boolean {
|
|
124
|
+
return has(d, "audio", "video", "track", "source", "object", "embed", "iframe", "marquee", "canvas");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Device-motion input: the only thing WCAG 2.5.4 is about. */
|
|
128
|
+
function hasMotionApi(d: Doc): boolean {
|
|
129
|
+
return /\b(?:devicemotion|deviceorientation|DeviceMotionEvent|DeviceOrientationEvent|Accelerometer|Gyroscope|requestPermission)\b/i.test(d.source);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Any control that takes user input, native or delegated. Over-inclusive on purpose. */
|
|
133
|
+
function hasFormControl(d: Doc): boolean {
|
|
134
|
+
if (has(d, "input", "select", "textarea", "form", "fieldset", "output", "datalist")) return true;
|
|
135
|
+
// A custom widget standing in for a field, and a submit control that implies one.
|
|
136
|
+
return d.elements.some((e) => {
|
|
137
|
+
const role = (e.attribs.role ?? "").toLowerCase();
|
|
138
|
+
if (role && /^(textbox|combobox|listbox|checkbox|radio|radiogroup|slider|spinbutton|searchbox|switch|form)$/.test(role)) return true;
|
|
139
|
+
return e.attribs.contenteditable !== undefined;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---- WHY a criterion is still to assess, and WHERE its evidence would come from -----------
|
|
144
|
+
//
|
|
145
|
+
// One generic sentence per tier used to answer for all 52 undecided criteria: "needs a rendered
|
|
146
|
+
// DOM (contrast, focus visibility, zoom/reflow, target size)" was printed against 1.4.5 (images
|
|
147
|
+
// of text) as readily as against 1.4.3 (contrast), naming four measurements of which at most
|
|
148
|
+
// one was the criterion's. A reader could not tell what was missing, and neither could the next
|
|
149
|
+
// run — so « to assess » read as a shrug rather than as an instruction.
|
|
150
|
+
//
|
|
151
|
+
// Each entry names the measurement that decides the criterion and the command that produces it.
|
|
152
|
+
// Absent ⇒ the tier default below, which is still true, just unspecific.
|
|
153
|
+
const RESIDUAL_TRAIL: Record<string, string> = {
|
|
154
|
+
// Rendering criteria the SNAPSHOT tier decides offline, from computed styles + stylesheets.
|
|
155
|
+
"1.3.4":
|
|
156
|
+
"Decided on a page's own stylesheets (an orientation media query that rotates the document) — record a snapshot with `scan --sample` (or an E2E capture), then re-audit.",
|
|
157
|
+
"1.4.1": "Decided on computed colours (a link distinguished by colour alone) — record a snapshot with `scan --sample`, then re-audit.",
|
|
158
|
+
"1.4.3":
|
|
159
|
+
"Decided on computed text/background colours, with a screenshot fallback where the backdrop is an image — record a snapshot with `scan --sample`, then re-audit.",
|
|
160
|
+
"1.4.11": "Decided on computed colours of UI components and graphics — record a snapshot with `scan --sample`, then re-audit.",
|
|
161
|
+
"2.4.7":
|
|
162
|
+
"Decided on the page's own stylesheets (a `:focus` rule that removes the indicator without restoring one), or by the live focus probe — record a snapshot with `scan --sample`, then re-audit.",
|
|
163
|
+
// Rendering criteria that need a LIVE browser: they are measured by acting on the page.
|
|
164
|
+
"1.4.4": "Needs a live browser: the page is re-measured at 200% zoom — `scan <url> --runtime local --merge <audit.json>`.",
|
|
165
|
+
"1.4.10": "Needs a live browser: the page is re-measured in a 320px viewport (reflow) — `scan <url> --merge <audit.json>`.",
|
|
166
|
+
"1.4.12": "Needs a live browser: text spacing overrides are injected and the layout re-measured — `scan <url> --runtime local --merge <audit.json>`.",
|
|
167
|
+
"1.4.13":
|
|
168
|
+
"Needs a live browser: hover/focus-triggered content is opened and probed for dismiss/hover/persist — `scan <url> --runtime local --merge <audit.json>`.",
|
|
169
|
+
// axe's own `target-size` rule can FAIL this on a live page, but no tier certifies it, so a
|
|
170
|
+
// clean scan leaves it open rather than conforming. Say both halves.
|
|
171
|
+
"2.5.8":
|
|
172
|
+
"A live scan can fail this (axe `target-size`) but never certify it — `scan <url> --runtime local --merge <audit.json>`, then adjudicate what is left.",
|
|
173
|
+
"4.1.3":
|
|
174
|
+
"Needs a live browser WITH interaction: a status message is triggered and the live region observed — `scan <url> --runtime local --merge <audit.json>` (interactions are on by default).",
|
|
175
|
+
// Rendering criteria no automated tier measures — say so, rather than pointing at `scan` and
|
|
176
|
+
// letting the reader discover it changes nothing.
|
|
177
|
+
"1.4.5":
|
|
178
|
+
"No automated tier decides this: whether text is presented as an image is a reading of each image's content. Adjudicate it (`verify --manual`) against the images the audit lists.",
|
|
179
|
+
"2.1.2": "No automated tier decides this: escaping a keyboard trap has to be attempted by hand, on each focusable region.",
|
|
180
|
+
"2.3.1": "No automated tier decides this: flashing has to be observed over time on the rendered page.",
|
|
181
|
+
"2.4.11": "No automated tier decides this: whether a focused element stays unobscured depends on the sticky headers and overlays in play on each screen.",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
function residualReason(automatability: string, sc?: string): string {
|
|
185
|
+
const trail = sc ? RESIDUAL_TRAIL[sc] : undefined;
|
|
186
|
+
if (trail) return trail;
|
|
68
187
|
return automatability === "needs-rendering"
|
|
69
188
|
? "Needs a rendered DOM (contrast, focus visibility, zoom/reflow, target size) — decide via `scan`."
|
|
70
189
|
: "Judgement criterion — adjudicated by the agent from source/context (`verify --manual`, gated).";
|
|
71
190
|
}
|
|
72
191
|
|
|
192
|
+
/** Why a non-static criterion is NA — the observation, so a reader can falsify it. Scope-bound
|
|
193
|
+
* by construction (`n` files audited), which is exactly the claim being made. */
|
|
194
|
+
function subjectMatterReason(sc: string, files: number): string {
|
|
195
|
+
const scope = `nothing in the ${files} file(s) audited is concerned`;
|
|
196
|
+
if (sc.startsWith("1.2."))
|
|
197
|
+
return `No time-based media in scope: ${scope} — no <audio>, <video>, <track>, <object>, <embed>, <iframe> or <canvas> element was found.`;
|
|
198
|
+
if (sc === "2.5.4") return `No motion actuation in scope: ${scope} — no device-motion or device-orientation API is used.`;
|
|
199
|
+
if (sc === "1.3.5" || sc.startsWith("3.3.")) return `No user input in scope: ${scope} — no form control (native, ARIA or contenteditable) was found.`;
|
|
200
|
+
return `No element in scope is concerned by this success criterion (${scope}).`;
|
|
201
|
+
}
|
|
202
|
+
|
|
73
203
|
// Streaming accumulator: parse → run rules → fold → DISCARD each Doc, so the
|
|
74
204
|
// engine never holds a whole repo in memory. The only cross-document state is
|
|
75
205
|
// (a) the flat findings list (bounded by finding count, not source size) and
|
|
@@ -87,6 +217,10 @@ interface Accum {
|
|
|
87
217
|
sfcExts: Set<string>; // which SFC extensions were seen
|
|
88
218
|
captures: { file: string; provenance: CaptureProvenance }[]; // rendered capture files audited
|
|
89
219
|
langCounts: Map<string, number>; // <html lang> primary subtags seen, for repo-language detection
|
|
220
|
+
// Success criteria the RENDERED tier measured, from the snapshots ingested in this run
|
|
221
|
+
// (src/rules/rendered.ts renderedTestedScs). Stamped onto scope.scan.testedScs so the
|
|
222
|
+
// partial-audit banner names what is genuinely missing instead of every rendering criterion.
|
|
223
|
+
renderedScs: Set<string>;
|
|
90
224
|
}
|
|
91
225
|
|
|
92
226
|
// Precompute the static success criteria + their applicability predicates once.
|
|
@@ -94,6 +228,13 @@ const STATIC_PREDS: ReadonlyArray<readonly [string, (d: Doc) => boolean]> = allS
|
|
|
94
228
|
.filter((c) => c.automatability === "static")
|
|
95
229
|
.map((c) => [c.sc, APPLICABLE[c.sc] ?? isFullDocument] as const);
|
|
96
230
|
|
|
231
|
+
// The same fold, for the non-static criteria whose subject matter is source-visible. Kept as a
|
|
232
|
+
// separate list so the `static` contract above ("clean ⇒ C") is untouched: a criterion here can
|
|
233
|
+
// only ever gain `NA`, never `C`.
|
|
234
|
+
const SUBJECT_PREDS: ReadonlyArray<readonly [string, (d: Doc) => boolean]> = allSC()
|
|
235
|
+
.filter((c) => c.automatability !== "static" && SUBJECT_MATTER[c.sc] !== undefined)
|
|
236
|
+
.map((c) => [c.sc, SUBJECT_MATTER[c.sc]!] as const);
|
|
237
|
+
|
|
97
238
|
function newAccum(): Accum {
|
|
98
239
|
return {
|
|
99
240
|
byCriterion: new Map(),
|
|
@@ -107,6 +248,7 @@ function newAccum(): Accum {
|
|
|
107
248
|
sfcExts: new Set(),
|
|
108
249
|
captures: [],
|
|
109
250
|
langCounts: new Map(),
|
|
251
|
+
renderedScs: new Set(),
|
|
110
252
|
};
|
|
111
253
|
}
|
|
112
254
|
|
|
@@ -166,6 +308,14 @@ export function foldDoc(acc: Accum, doc: Doc, graph?: DepGraph): void {
|
|
|
166
308
|
for (const [id, pred] of STATIC_PREDS) {
|
|
167
309
|
if (!acc.applicable.get(id) && pred(doc)) acc.applicable.set(id, true);
|
|
168
310
|
}
|
|
311
|
+
// OR-folded across the whole scope: one document carrying the subject matter keeps the
|
|
312
|
+
// criterion open for the entire audit (see SUBJECT_MATTER, rule 2).
|
|
313
|
+
for (const [id, pred] of SUBJECT_PREDS) {
|
|
314
|
+
if (!acc.applicable.get(id) && pred(doc)) acc.applicable.set(id, true);
|
|
315
|
+
}
|
|
316
|
+
// A page snapshot just let the rendered tier MEASURE some criteria, offline and with no
|
|
317
|
+
// browser. Record which, so the report stops claiming they were never tested.
|
|
318
|
+
if (doc.signals) for (const sc of renderedTestedScs(doc.signals)) acc.renderedScs.add(sc);
|
|
169
319
|
if (doc.opaqueComponents?.length) {
|
|
170
320
|
for (const lib of doc.opaqueComponents) acc.opaqueLibs.add(lib);
|
|
171
321
|
acc.opaqueFiles++;
|
|
@@ -215,11 +365,17 @@ function finalize(acc: Accum, inputs: string[], extra: FinalizeExtra = {}): Audi
|
|
|
215
365
|
} else if (normativeFs.length > 0) {
|
|
216
366
|
// a rule on a needs-rendering / judgment SC raised a DEFINITE failure
|
|
217
367
|
status = "NC";
|
|
368
|
+
} else if (SUBJECT_MATTER[c.sc] !== undefined && !(acc.applicable.get(c.sc) ?? false)) {
|
|
369
|
+
// The criterion's SUBJECT MATTER is absent from the entire scope, so there is nothing to
|
|
370
|
+
// judge and nothing to render — « not applicable » is the accurate verdict, not « to
|
|
371
|
+
// assess ». Never a `C`: this says the criterion does not apply, never that it is met.
|
|
372
|
+
status = "NA";
|
|
373
|
+
justification = subjectMatterReason(c.sc, acc.fileCount);
|
|
218
374
|
} else {
|
|
219
375
|
// engine can't decide — leave it for the agent to adjudicate (`verify --manual`,
|
|
220
376
|
// gated) or the `scan` tier (rendering criteria); never a silent conforming.
|
|
221
377
|
status = "manual";
|
|
222
|
-
residualRisks.push({ criteriaId: c.sc, reason: residualReason(c.automatability), automatability: c.automatability });
|
|
378
|
+
residualRisks.push({ criteriaId: c.sc, reason: residualReason(c.automatability, c.sc), automatability: c.automatability });
|
|
223
379
|
}
|
|
224
380
|
criteria.push({ id: c.sc, guideline: c.guideline, status, findings: fs, ...(justification ? { justification } : {}) });
|
|
225
381
|
}
|
|
@@ -262,6 +418,11 @@ function finalize(acc: Accum, inputs: string[], extra: FinalizeExtra = {}): Audi
|
|
|
262
418
|
}
|
|
263
419
|
: {}),
|
|
264
420
|
...(acc.langCounts.size ? { langs: [...acc.langCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([lang]) => lang) } : {}),
|
|
421
|
+
// Coverage the RENDERED tier earned from the snapshots this run ingested. Same field the
|
|
422
|
+
// live-browser merge stamps (`mergeDynamic`), because it is the same claim: these criteria
|
|
423
|
+
// were measured. Omitted when no snapshot carried usable signals, so a source-only audit
|
|
424
|
+
// is byte-for-byte unchanged and still says the rendering criteria are untested.
|
|
425
|
+
...(acc.renderedScs.size ? { scan: { testedScs: [...acc.renderedScs].sort() } } : {}),
|
|
265
426
|
// The pages this run genuinely read. Written UNCONDITIONALLY — `[]` is the whole point,
|
|
266
427
|
// because "this audit read no page" is exactly the claim a source-only run needs to make,
|
|
267
428
|
// and an omit-when-empty field would say nothing precisely then. See scope.pagesAudited.
|