ultra11y 5.12.0 → 5.13.1

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.
@@ -10,7 +10,7 @@
10
10
  "name": "ultra11y",
11
11
  "source": "./",
12
12
  "description": "Automatic accessibility review of the code under change before every commit, push and pull request, plus full WCAG 2.2 AA / RGAA audits and conformance deliverables on demand.",
13
- "version": "5.12.0",
13
+ "version": "5.13.1",
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…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "5.12.0",
4
+ "version": "5.13.1",
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…): an automatic review of the code under change before every commit, push and pull request, plus full audits and conformance deliverables on demand.",
4
- "version": "5.12.0",
4
+ "version": "5.13.1",
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.12.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.13.1";
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.12.0",
3
+ "version": "5.13.1",
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",
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, 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.12.0";
9
+ var VERSION = "5.13.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -39283,6 +39283,12 @@ var AXE_WCAG = {
39283
39283
  bypass: "2.4.1",
39284
39284
  accesskeys: "2.1.1"
39285
39285
  };
39286
+ var AXE_DECIDES = {
39287
+ // Text contrast. `color-contrast` is on by default, runs on every text node, and computes
39288
+ // the ratio the criterion states — the one criterion where the automated answer IS the
39289
+ // answer, which is why the local scan tier has always mapped it here.
39290
+ "1.4.3": ["color-contrast"]
39291
+ };
39286
39292
  var FALLBACK_SC = "4.1.2";
39287
39293
  function scFromWcagTags(tags) {
39288
39294
  if (!tags) return null;
@@ -48561,6 +48567,7 @@ function foldDoc(acc, doc, graph) {
48561
48567
  const axe = doc.signals?.axe;
48562
48568
  if (axe?.ran) {
48563
48569
  acc.axeRan.add(pageId);
48570
+ for (const sc of Object.keys(AXE_DECIDES)) acc.renderedScs.add(sc);
48564
48571
  for (const f of axeFindings(axe.violations ?? [], doc.file, pageId)) {
48565
48572
  const list = acc.byCriterion.get(f.criteriaId) ?? [];
48566
48573
  list.push(f);
@@ -48589,6 +48596,7 @@ function renderedProves(sc, acc) {
48589
48596
  if (acc.pageIds.size === 0) return false;
48590
48597
  const probed = acc.probedScs.get(sc);
48591
48598
  if (probed && probed.size === acc.pageIds.size) return true;
48599
+ if (AXE_DECIDES[sc] && acc.axeRan.size === acc.pageIds.size) return true;
48592
48600
  const rules = renderedRulesFor(sc);
48593
48601
  if (!rules.length) return false;
48594
48602
  return rules.every((ruleId) => {
@@ -48605,6 +48613,9 @@ function partialProbeReason(sc, acc) {
48605
48613
  return `Probed on ${probed.size} of the ${acc.pageIds.size} pages in scope, so this criterion stays open: conformity here is measured on EVERY page or on none, and the page nobody probed is exactly where the failure would be. Not probed: ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}.`;
48606
48614
  }
48607
48615
  function renderedProvesReason(sc, acc) {
48616
+ if (AXE_DECIDES[sc] && acc.axeRan.size === acc.pageIds.size) {
48617
+ return `Measured by axe-core on all ${acc.pageIds.size} page(s) in scope \u2014 ${AXE_DECIDES[sc]?.join(", ")} ran in the browser and reported nothing. A page axe had not run on would have kept this criterion open.`;
48618
+ }
48608
48619
  const probed = acc.probedScs.get(sc);
48609
48620
  if (probed && probed.size === acc.pageIds.size) {
48610
48621
  return `Measured in a real browser on all ${acc.pageIds.size} page(s) in scope \u2014 the probe acted on the page (zoom, 320px viewport, text-spacing override, Tab, hover) and observed nothing. A page the probe had not run on would have kept this criterion open.`;
@@ -54362,6 +54373,7 @@ function pageStatus(c2, pageFindings, basis) {
54362
54373
  if (pageFindings.some((f) => !f.advisory)) return "NC";
54363
54374
  if (c2.status === "manual") return "manual";
54364
54375
  if (c2.status === "NA") return "NA";
54376
+ if (c2.inapplicable) return c2.status;
54365
54377
  if (c2.decidedBy === "agent" || c2.decidedBy === "scan") return c2.status;
54366
54378
  if (automatability(c2.id) !== "static") return "manual";
54367
54379
  return basis === "snapshot" ? "C" : "manual";
@@ -54379,13 +54391,17 @@ function derivePages(result, pages) {
54379
54391
  const own = result.findings.filter((f) => f.page === p.id);
54380
54392
  const criteria = result.criteria.map((c2) => {
54381
54393
  const pf = own.filter((f) => f.criteriaId === c2.id);
54394
+ const status = pageStatus(c2, pf, p.basis);
54382
54395
  return {
54383
54396
  id: c2.id,
54384
54397
  guideline: c2.guideline,
54385
- status: pageStatus(c2, pf, p.basis),
54398
+ status,
54386
54399
  findings: pf,
54387
54400
  ...c2.justification ? { justification: c2.justification } : {},
54388
- ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {}
54401
+ ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {},
54402
+ // Carried, not recomputed: a finding on THIS page proves the subject exists after all,
54403
+ // and `pageStatus` has already turned that into an NC above.
54404
+ ...c2.inapplicable && status === c2.status ? { inapplicable: true } : {}
54389
54405
  };
54390
54406
  });
54391
54407
  const { rate, decided, total } = pct(criteria);
@@ -5,7 +5,7 @@ when_to_use: "Invoke WITHOUT being asked whenever changed front-end code should
5
5
  allowed-tools: Bash(node ${CLAUDE_SKILL_DIR}/scripts/ultra11y.mjs *), Read, Grep, Glob
6
6
  license: MIT
7
7
  metadata:
8
- version: 5.12.0
8
+ version: 5.13.1
9
9
  ---
10
10
 
11
11
  # review-a11y — accessibility review of changed code
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, 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.12.0";
9
+ var VERSION = "5.13.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -39283,6 +39283,12 @@ var AXE_WCAG = {
39283
39283
  bypass: "2.4.1",
39284
39284
  accesskeys: "2.1.1"
39285
39285
  };
39286
+ var AXE_DECIDES = {
39287
+ // Text contrast. `color-contrast` is on by default, runs on every text node, and computes
39288
+ // the ratio the criterion states — the one criterion where the automated answer IS the
39289
+ // answer, which is why the local scan tier has always mapped it here.
39290
+ "1.4.3": ["color-contrast"]
39291
+ };
39286
39292
  var FALLBACK_SC = "4.1.2";
39287
39293
  function scFromWcagTags(tags) {
39288
39294
  if (!tags) return null;
@@ -48561,6 +48567,7 @@ function foldDoc(acc, doc, graph) {
48561
48567
  const axe = doc.signals?.axe;
48562
48568
  if (axe?.ran) {
48563
48569
  acc.axeRan.add(pageId);
48570
+ for (const sc of Object.keys(AXE_DECIDES)) acc.renderedScs.add(sc);
48564
48571
  for (const f of axeFindings(axe.violations ?? [], doc.file, pageId)) {
48565
48572
  const list = acc.byCriterion.get(f.criteriaId) ?? [];
48566
48573
  list.push(f);
@@ -48589,6 +48596,7 @@ function renderedProves(sc, acc) {
48589
48596
  if (acc.pageIds.size === 0) return false;
48590
48597
  const probed = acc.probedScs.get(sc);
48591
48598
  if (probed && probed.size === acc.pageIds.size) return true;
48599
+ if (AXE_DECIDES[sc] && acc.axeRan.size === acc.pageIds.size) return true;
48592
48600
  const rules = renderedRulesFor(sc);
48593
48601
  if (!rules.length) return false;
48594
48602
  return rules.every((ruleId) => {
@@ -48605,6 +48613,9 @@ function partialProbeReason(sc, acc) {
48605
48613
  return `Probed on ${probed.size} of the ${acc.pageIds.size} pages in scope, so this criterion stays open: conformity here is measured on EVERY page or on none, and the page nobody probed is exactly where the failure would be. Not probed: ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}.`;
48606
48614
  }
48607
48615
  function renderedProvesReason(sc, acc) {
48616
+ if (AXE_DECIDES[sc] && acc.axeRan.size === acc.pageIds.size) {
48617
+ return `Measured by axe-core on all ${acc.pageIds.size} page(s) in scope \u2014 ${AXE_DECIDES[sc]?.join(", ")} ran in the browser and reported nothing. A page axe had not run on would have kept this criterion open.`;
48618
+ }
48608
48619
  const probed = acc.probedScs.get(sc);
48609
48620
  if (probed && probed.size === acc.pageIds.size) {
48610
48621
  return `Measured in a real browser on all ${acc.pageIds.size} page(s) in scope \u2014 the probe acted on the page (zoom, 320px viewport, text-spacing override, Tab, hover) and observed nothing. A page the probe had not run on would have kept this criterion open.`;
@@ -54362,6 +54373,7 @@ function pageStatus(c2, pageFindings, basis) {
54362
54373
  if (pageFindings.some((f) => !f.advisory)) return "NC";
54363
54374
  if (c2.status === "manual") return "manual";
54364
54375
  if (c2.status === "NA") return "NA";
54376
+ if (c2.inapplicable) return c2.status;
54365
54377
  if (c2.decidedBy === "agent" || c2.decidedBy === "scan") return c2.status;
54366
54378
  if (automatability(c2.id) !== "static") return "manual";
54367
54379
  return basis === "snapshot" ? "C" : "manual";
@@ -54379,13 +54391,17 @@ function derivePages(result, pages) {
54379
54391
  const own = result.findings.filter((f) => f.page === p.id);
54380
54392
  const criteria = result.criteria.map((c2) => {
54381
54393
  const pf = own.filter((f) => f.criteriaId === c2.id);
54394
+ const status = pageStatus(c2, pf, p.basis);
54382
54395
  return {
54383
54396
  id: c2.id,
54384
54397
  guideline: c2.guideline,
54385
- status: pageStatus(c2, pf, p.basis),
54398
+ status,
54386
54399
  findings: pf,
54387
54400
  ...c2.justification ? { justification: c2.justification } : {},
54388
- ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {}
54401
+ ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {},
54402
+ // Carried, not recomputed: a finding on THIS page proves the subject exists after all,
54403
+ // and `pageStatus` has already turned that into an NC above.
54404
+ ...c2.inapplicable && status === c2.status ? { inapplicable: true } : {}
54389
54405
  };
54390
54406
  });
54391
54407
  const { rate, decided, total } = pct(criteria);
@@ -4,7 +4,7 @@ description: "Use to AUDIT existing HTML/CSS/JSX against WCAG 2.2 AA accessibili
4
4
  when_to_use: "Invoke when the user asks for an accessibility AUDIT or a formal deliverable: audit a repo, site or page against WCAG 2.2 AA or a country standard (RGAA, Section 508, EN 301 549); produce a dated conformance report, a criterion grid, a PRD backlog, or tickets (GitHub/GitLab/Jira); author accessible markup from scratch; wire the repo gate (init --hook/--ci) or a rendered-DOM capture pipeline. For a review of the code UNDER CHANGE (diff, branch, PR), use the `review-a11y` skill instead."
5
5
  license: MIT
6
6
  metadata:
7
- version: 5.12.0
7
+ version: 5.13.1
8
8
  ---
9
9
 
10
10
  # ultra11y — audit WCAG 2.2 AA and write accessible markup
@@ -6,7 +6,7 @@ import { join as join51, relative as relative5, 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.12.0";
9
+ var VERSION = "5.13.1";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -39283,6 +39283,12 @@ var AXE_WCAG = {
39283
39283
  bypass: "2.4.1",
39284
39284
  accesskeys: "2.1.1"
39285
39285
  };
39286
+ var AXE_DECIDES = {
39287
+ // Text contrast. `color-contrast` is on by default, runs on every text node, and computes
39288
+ // the ratio the criterion states — the one criterion where the automated answer IS the
39289
+ // answer, which is why the local scan tier has always mapped it here.
39290
+ "1.4.3": ["color-contrast"]
39291
+ };
39286
39292
  var FALLBACK_SC = "4.1.2";
39287
39293
  function scFromWcagTags(tags) {
39288
39294
  if (!tags) return null;
@@ -48561,6 +48567,7 @@ function foldDoc(acc, doc, graph) {
48561
48567
  const axe = doc.signals?.axe;
48562
48568
  if (axe?.ran) {
48563
48569
  acc.axeRan.add(pageId);
48570
+ for (const sc of Object.keys(AXE_DECIDES)) acc.renderedScs.add(sc);
48564
48571
  for (const f of axeFindings(axe.violations ?? [], doc.file, pageId)) {
48565
48572
  const list = acc.byCriterion.get(f.criteriaId) ?? [];
48566
48573
  list.push(f);
@@ -48589,6 +48596,7 @@ function renderedProves(sc, acc) {
48589
48596
  if (acc.pageIds.size === 0) return false;
48590
48597
  const probed = acc.probedScs.get(sc);
48591
48598
  if (probed && probed.size === acc.pageIds.size) return true;
48599
+ if (AXE_DECIDES[sc] && acc.axeRan.size === acc.pageIds.size) return true;
48592
48600
  const rules = renderedRulesFor(sc);
48593
48601
  if (!rules.length) return false;
48594
48602
  return rules.every((ruleId) => {
@@ -48605,6 +48613,9 @@ function partialProbeReason(sc, acc) {
48605
48613
  return `Probed on ${probed.size} of the ${acc.pageIds.size} pages in scope, so this criterion stays open: conformity here is measured on EVERY page or on none, and the page nobody probed is exactly where the failure would be. Not probed: ${shown.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}.`;
48606
48614
  }
48607
48615
  function renderedProvesReason(sc, acc) {
48616
+ if (AXE_DECIDES[sc] && acc.axeRan.size === acc.pageIds.size) {
48617
+ return `Measured by axe-core on all ${acc.pageIds.size} page(s) in scope \u2014 ${AXE_DECIDES[sc]?.join(", ")} ran in the browser and reported nothing. A page axe had not run on would have kept this criterion open.`;
48618
+ }
48608
48619
  const probed = acc.probedScs.get(sc);
48609
48620
  if (probed && probed.size === acc.pageIds.size) {
48610
48621
  return `Measured in a real browser on all ${acc.pageIds.size} page(s) in scope \u2014 the probe acted on the page (zoom, 320px viewport, text-spacing override, Tab, hover) and observed nothing. A page the probe had not run on would have kept this criterion open.`;
@@ -54362,6 +54373,7 @@ function pageStatus(c2, pageFindings, basis) {
54362
54373
  if (pageFindings.some((f) => !f.advisory)) return "NC";
54363
54374
  if (c2.status === "manual") return "manual";
54364
54375
  if (c2.status === "NA") return "NA";
54376
+ if (c2.inapplicable) return c2.status;
54365
54377
  if (c2.decidedBy === "agent" || c2.decidedBy === "scan") return c2.status;
54366
54378
  if (automatability(c2.id) !== "static") return "manual";
54367
54379
  return basis === "snapshot" ? "C" : "manual";
@@ -54379,13 +54391,17 @@ function derivePages(result, pages) {
54379
54391
  const own = result.findings.filter((f) => f.page === p.id);
54380
54392
  const criteria = result.criteria.map((c2) => {
54381
54393
  const pf = own.filter((f) => f.criteriaId === c2.id);
54394
+ const status = pageStatus(c2, pf, p.basis);
54382
54395
  return {
54383
54396
  id: c2.id,
54384
54397
  guideline: c2.guideline,
54385
- status: pageStatus(c2, pf, p.basis),
54398
+ status,
54386
54399
  findings: pf,
54387
54400
  ...c2.justification ? { justification: c2.justification } : {},
54388
- ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {}
54401
+ ...c2.decidedBy ? { decidedBy: c2.decidedBy } : {},
54402
+ // Carried, not recomputed: a finding on THIS page proves the subject exists after all,
54403
+ // and `pageStatus` has already turned that into an NC above.
54404
+ ...c2.inapplicable && status === c2.status ? { inapplicable: true } : {}
54389
54405
  };
54390
54406
  });
54391
54407
  const { rate, decided, total } = pct(criteria);
package/src/audit.ts CHANGED
@@ -13,7 +13,7 @@ import { attr, elementsByTag, type Doc, type CaptureProvenance } from "./parse/h
13
13
  import { CAPTURES_DIR, computeCaptureCoverage, enrichCaptureOrigins, isUnderDir, readCaptureDir, capturesForSources } from "./capture.js";
14
14
  import { isFullDocument } from "./rules/rule.js";
15
15
  import { renderedRulesFor, renderedRulesRan, renderedTestedScs } from "./rules/rendered.js";
16
- import { PROBE_SEVERITY, PROBE_WCAG, isAxeAdvisory, scForAxe, severityFromImpact } from "./axe-map.js";
16
+ import { AXE_DECIDES, PROBE_SEVERITY, PROBE_WCAG, isAxeAdvisory, scForAxe, severityFromImpact } from "./axe-map.js";
17
17
  import { subjectsAbsent, subjectsForSc, subjectsPresentIn } from "./adjudicate-subjects.js";
18
18
  import { runRules } from "./rules/registry.js";
19
19
  import { runCrossRules } from "./rules/cross-registry.js";
@@ -526,6 +526,7 @@ export function foldDoc(acc: Accum, doc: Doc, graph?: DepGraph): void {
526
526
  const axe = doc.signals?.axe;
527
527
  if (axe?.ran) {
528
528
  acc.axeRan.add(pageId);
529
+ for (const sc of Object.keys(AXE_DECIDES)) acc.renderedScs.add(sc);
529
530
  for (const f of axeFindings(axe.violations ?? [], doc.file, pageId)) {
530
531
  const list = acc.byCriterion.get(f.criteriaId) ?? [];
531
532
  list.push(f);
@@ -585,6 +586,9 @@ function renderedProves(sc: string, acc: Accum): boolean {
585
586
  // in scope, or nothing.
586
587
  const probed = acc.probedScs.get(sc);
587
588
  if (probed && probed.size === acc.pageIds.size) return true;
589
+ // AXE, on every page in scope, for the handful of criteria it is the canonical decider of
590
+ // (AXE_DECIDES). Same rule as the probes and the digest tier: every page, or nothing.
591
+ if (AXE_DECIDES[sc] && acc.axeRan.size === acc.pageIds.size) return true;
588
592
  const rules = renderedRulesFor(sc);
589
593
  if (!rules.length) return false;
590
594
  return rules.every((ruleId) => {
@@ -610,6 +614,9 @@ function partialProbeReason(sc: string, acc: Accum): string | undefined {
610
614
  }
611
615
 
612
616
  function renderedProvesReason(sc: string, acc: Accum): string {
617
+ if (AXE_DECIDES[sc] && acc.axeRan.size === acc.pageIds.size) {
618
+ return `Measured by axe-core on all ${acc.pageIds.size} page(s) in scope — ${AXE_DECIDES[sc]?.join(", ")} ran in the browser and reported nothing. A page axe had not run on would have kept this criterion open.`;
619
+ }
613
620
  const probed = acc.probedScs.get(sc);
614
621
  if (probed && probed.size === acc.pageIds.size) {
615
622
  return `Measured in a real browser on all ${acc.pageIds.size} page(s) in scope — the probe acted on the page (zoom, 320px viewport, text-spacing override, Tab, hover) and observed nothing. A page the probe had not run on would have kept this criterion open.`;
package/src/axe-map.ts CHANGED
@@ -128,6 +128,27 @@ export const AXE_WCAG: Record<string, string> = {
128
128
  accesskeys: "2.1.1",
129
129
  };
130
130
 
131
+ /** THE SUCCESS CRITERIA AN AXE PASS THAT FOUND NOTHING IS ALLOWED TO CLOSE.
132
+ *
133
+ * Deliberately tiny, and deliberately a list rather than a rule. axe REPORTS on dozens of
134
+ * success criteria (that is what AXE_WCAG above is for), but reporting on one is not the same
135
+ * as being its canonical decider: `color-contrast` computes the very ratio 1.4.3 is written
136
+ * in, while axe's ARIA rules cover a slice of 4.1.2 that no clean run can close. Reading the
137
+ * second as the first is how a grid fills up with conformities nobody verified.
138
+ *
139
+ * The guardrail around it is in src/audit.ts and is the same one the probes get: the pass must
140
+ * have run on EVERY page in scope. A criterion measured on some pages is not a criterion
141
+ * measured.
142
+ *
143
+ * Entries are added one at a time, each with the rule that carries it and a reason to trust
144
+ * it — never in bulk from axe's tag list. */
145
+ export const AXE_DECIDES: Record<string, readonly string[]> = {
146
+ // Text contrast. `color-contrast` is on by default, runs on every text node, and computes
147
+ // the ratio the criterion states — the one criterion where the automated answer IS the
148
+ // answer, which is why the local scan tier has always mapped it here.
149
+ "1.4.3": ["color-contrast"],
150
+ };
151
+
131
152
  export const FALLBACK_SC = "4.1.2";
132
153
 
133
154
  /** Read a WCAG SC from axe's native rule tags. axe tags each rule with `wcag<abc>`
package/src/pages.ts CHANGED
@@ -165,7 +165,16 @@ function pageStatus(c: CriterionResult, pageFindings: Finding[], basis: PageScop
165
165
  // A non-normative recommendation can never flip a criterion to NC — same rule as core.
166
166
  if (pageFindings.some((f) => !f.advisory)) return "NC";
167
167
  if (c.status === "manual") return "manual"; // the engine cannot decide it anywhere
168
- if (c.status === "NA") return "NA"; // not applicable in scope ⇒ not applicable here
168
+ if (c.status === "NA") return "NA"; // legacy audits only — the engine no longer emits it
169
+ // NOTHING OF THAT KIND EXISTS ANYWHERE IN SCOPE, so there is none on this page either.
170
+ //
171
+ // Checked BEFORE rule 3 below, and it is not an exception to it: rule 3 refuses to conclude
172
+ // from silence, and this is not silence — it is a scope-wide absence the engine can point at,
173
+ // carrying the justification that names the subject it looked for. Left to fall through, every
174
+ // media, table and form criterion came back « à évaluer » on each page while the run-wide grid
175
+ // had them settled: measured on a three-page sample, 75 of 106 RGAA criteria open per page
176
+ // against 26 for the run.
177
+ if (c.inapplicable) return c.status;
169
178
 
170
179
  // A verdict SOMEBODY RECORDED is not silence, and rule 3 below is only about silence.
171
180
  //
@@ -225,13 +234,17 @@ export function derivePages(result: AuditResult, pages: PageScope[]): PageResult
225
234
  const own = result.findings.filter((f) => f.page === p.id);
226
235
  const criteria: CriterionResult[] = result.criteria.map((c) => {
227
236
  const pf = own.filter((f) => f.criteriaId === c.id);
237
+ const status = pageStatus(c, pf, p.basis);
228
238
  return {
229
239
  id: c.id,
230
240
  guideline: c.guideline,
231
- status: pageStatus(c, pf, p.basis),
241
+ status,
232
242
  findings: pf,
233
243
  ...(c.justification ? { justification: c.justification } : {}),
234
244
  ...(c.decidedBy ? { decidedBy: c.decidedBy } : {}),
245
+ // Carried, not recomputed: a finding on THIS page proves the subject exists after all,
246
+ // and `pageStatus` has already turned that into an NC above.
247
+ ...(c.inapplicable && status === c.status ? { inapplicable: true } : {}),
235
248
  };
236
249
  });
237
250
  const { rate, decided, total } = pct(criteria);
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.12.0";
3
+ export const VERSION = "5.13.1";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,