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/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 { groundItems, type GroundingSummary } from "./grounding.js";
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
- docs.push(parseSource(readText(cwd ? join(cwd, f) : f), f));
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. FAIL-CLOSED (see module header). Returns
508
- * a NEW AuditResult with the decided statuses, agent findings, recomputed conformancePct,
509
- * a shrunk residual set, and the `adjudicated` marker. */
510
- export function applyAdjudication(audit: AuditResult, adj: AdjudicationFile, opts: { cwd?: string } = {}): ApplyAdjudicationResult {
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 (!byId.has(pc.id)) issues.push(`criterion ${pc.id}: missing from the adjudication (coverage gap)`);
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 (!byId.has(r.criteriaId)) issues.push(`criterion ${r.criteriaId}: missing from the adjudication (coverage gap)`);
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
- issues.push(`criterion ${it.criteriaId}: not open for adjudication — the engine already decided it, or it is not part of ${adj.standard}`);
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
- const groundInputs: { file: string; line: number; selector?: string; snippet?: string }[] = [];
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
- issues.push(`criterion ${it.criteriaId}: unadjudicated (verdict is null)`);
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()) issues.push(`criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
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
- issues.push(
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
- issues.push(
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
- issues.push(`criterion ${it.criteriaId}: citation ${c.file}:${c.line} is not among this criterion's harvested evidence (fabricated?)`);
645
+ blame(it.criteriaId, `criterion ${it.criteriaId}: citation ${c.file}:${c.line} is not among this criterion's harvested evidence (fabricated?)`);
580
646
  }
581
- groundInputs.push({ file: c.file, line: c.line, selector: c.selector, snippet: c.snippet });
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) issues.push(`criterion ${it.criteriaId}: an NC verdict requires at least one groundable finding`);
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
- issues.push(`criterion ${it.criteriaId}: an NC finding requires a normativeRef citing the failed test of the active standard`);
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
- issues.push(
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
- groundInputs.push({ file: f.file, line: f.line, selector: f.selector, snippet: f.snippet });
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
- issues.push(`criterion ${it.criteriaId}: a manual verdict requires reason ∈ {${MANUAL_REASON_VALUES.join(", ")}}`);
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
- issues.push(`criterion ${it.criteriaId}: unknown verdict "${String(v)}" — expected one of ${VERDICTS.join(" | ")}`);
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 ?? []) groundInputs.push({ file: rec.file, line: rec.line, selector: rec.selector, snippet: rec.snippet });
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
- const grounding = groundItems(groundInputs, { cwd: opts.cwd });
618
- for (const gi of grounding.issues) issues.push(gi);
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
- if (issues.length) {
621
- return { ok: false, audit, issues, applied: 0, stillManual: 0, grounding };
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: true, audit: next, issues, applied, stillManual, grounding };
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 only the still-manual criteria.
709
- next.residualRisks = next.residualRisks.filter((r) => byId.get(r.criteriaId)?.verdict === "manual");
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: true, audit: next, issues: [], applied, stillManual, grounding };
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
- function residualReason(automatability: string): string {
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.