ultra11y 5.25.0 → 5.27.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.
@@ -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.25.0",
13
+ "version": "5.27.0",
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.25.0",
4
+ "version": "5.27.0",
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.25.0",
4
+ "version": "5.27.0",
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.25.0";
30
+ const ULTRA11Y_PLUGIN_VERSION = "5.27.0";
31
31
 
32
32
  const here = dirname(fileURLToPath(import.meta.url));
33
33
 
@@ -720,7 +720,7 @@ var test = (() => {
720
720
  const base = load(from).test;
721
721
  if (!base?.extend) continue;
722
722
  return base.extend({
723
- // biome-ignore lint/suspicious/noExplicitAny: same
723
+ // `any` for the same reason.
724
724
  ultra11y: async ({ page }, use) => {
725
725
  await use((opts) => checkA11y(page, opts));
726
726
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ultra11y",
3
- "version": "5.25.0",
3
+ "version": "5.27.0",
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.25.0";
9
+ var VERSION = "5.27.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -36908,7 +36908,7 @@ function makeFilter(opts = {}) {
36908
36908
  const rel2 = toPosix(file);
36909
36909
  if (include && !include(rel2)) return false;
36910
36910
  if (exclude?.(rel2)) return false;
36911
- if (!opts.noDefaultExcludes && isTestArtifact(rel2) && !(include && include(rel2))) return false;
36911
+ if (!opts.noDefaultExcludes && isTestArtifact(rel2) && !include?.(rel2)) return false;
36912
36912
  return true;
36913
36913
  };
36914
36914
  }
@@ -36974,7 +36974,7 @@ function expandInputs(inputs, opts = {}) {
36974
36974
  const before = list.length;
36975
36975
  list = list.filter((f) => {
36976
36976
  const rel2 = toPosix(f);
36977
- return !isTestArtifact(rel2) || explicit.has(f) || include != null && include(rel2);
36977
+ return !isTestArtifact(rel2) || explicit.has(f) || include?.(rel2);
36978
36978
  });
36979
36979
  const dropped = before - list.length;
36980
36980
  if (dropped) opts.onWarn?.(`ultra11y: skipped ${dropped} test/spec/story file(s) \u2014 pass --no-default-excludes to audit them.`);
@@ -45061,14 +45061,42 @@ var SUBJECTS = {
45061
45061
  (e) => h(d, e, `<${e.tag} autoplay=${attr(e, "autoplay") !== void 0} controls=${attr(e, "controls") !== void 0}>`, `media|${e.tag}`)
45062
45062
  )
45063
45063
  ]),
45064
- // A change of context triggered by focus or by changing a value.
45065
- // RGAA 7.4 / WCAG 3.2.1-3.2.2. The handlers are half the subject; the OTHER half is what a
45066
- // script does once it runs, and that is where a context change actually happens: submitting a
45067
- // form, navigating, opening a window. Looking only for onFocus/onBlur/onChange left the
45068
- // criterion with an empty harvest on any codebase that binds its handlers in JavaScript
45069
- // rather than in markup — which is most of them — and an empty harvest is a criterion the
45070
- // adjudicator can only guess at.
45064
+ // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
45065
+ //
45066
+ // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
45067
+ // CONSEQUENCES — onFocus/onBlur/onChange, `.submit(`, `location`, `router.push`,
45068
+ // `window.open` — on the theory that a context change is one of them. It is, but the list can
45069
+ // only ever be the ways we have already seen a script change context, and a criterion whose
45070
+ // subject is « each script » cannot be answered from a catalogue of endings.
45071
+ //
45072
+ // Measured on tests/fixtures/realworld, which ships LoginForm.tsx whose form carries
45073
+ // `onSubmit={(e) => { e.preventDefault(); … }}`: 7.4 reached the adjudicator with population
45074
+ // ZERO. A script was right there, doing the thing the criterion asks about, and the harvest
45075
+ // walked past it because `onSubmit` was not on the list. The adjudicator was told « no
45076
+ // evidence » and billed to guess between NA and manual — on a page it should have been handed
45077
+ // twenty lines of React to read.
45078
+ //
45079
+ // So it collects the script FIRST: any `<script>`, any `on*` binding whatever the event, and
45080
+ // then the consequences it always looked for, which stay because they are what an adjudicator
45081
+ // most needs pointed out. Two things follow. A page with a script always arrives with it, so
45082
+ // the verdict is made on the code rather than on its absence. And a page with no script at all
45083
+ // harvests nothing — which is now a FACT about the code, not a gap in a pattern list, so 7.4
45084
+ // and 3.2.1/3.2.2 close as « not applicable » with no model in the loop (EXISTENCE_SUBJECTS).
45071
45085
  contextChange: (docs) => docs.flatMap((d) => [
45086
+ // The script itself — the subject, before anything it goes on to do.
45087
+ ...elementsByTag(d, "script").map(
45088
+ (e) => h(
45089
+ d,
45090
+ e,
45091
+ `<script${attr(e, "src") ? ` src="${attr(e, "src")}"` : ""}> \u2014 does anything it runs change context, and is the user warned or in control?`,
45092
+ `script|${attr(e, "src") ?? "inline"}`
45093
+ )
45094
+ ),
45095
+ // Any handler binding, whatever the event: `onSubmit` and `onClick` change context as
45096
+ // readily as `onChange`, and a criterion about scripts has no business enumerating events.
45097
+ ...linesOf(d, /\bon[A-Z][A-Za-z]*\s*=|\son[a-z]+\s*=\s*["']/).map(
45098
+ (l) => hAt(d, l.line, "handler", `handler \u2014 does it change context (navigate, submit, open a window, move focus)? ${l.text}`, `handler|${l.text}`)
45099
+ ),
45072
45100
  ...linesOf(d, /\bon(?:Focus|Blur)\s*=/).map(
45073
45101
  (l) => hAt(d, l.line, "handler", `focus handler \u2014 does it change context (navigate, submit, move focus)? ${l.text}`, `onfocus|${l.text}`)
45074
45102
  ),
@@ -45436,9 +45464,12 @@ var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set([
45436
45464
  "autocomplete",
45437
45465
  "errors",
45438
45466
  "frames",
45439
- "downloadDocs"
45467
+ "downloadDocs",
45468
+ "newWindow",
45469
+ "contextChange"
45440
45470
  ]);
45441
45471
  var subjectsForSc = (sc) => SC_SUBJECTS[sc] ?? [];
45472
+ var ownPackSubjects = (standard, id) => PACK_SUBJECTS[standard]?.[id];
45442
45473
  function subjectsForPackCriterion(standard, id, scs) {
45443
45474
  const own = PACK_SUBJECTS[standard]?.[id];
45444
45475
  if (own) return own;
@@ -46968,7 +46999,7 @@ var tableEmptyDataCell = {
46968
46999
  return out2;
46969
47000
  }
46970
47001
  };
46971
- function ownerTable(el, doc) {
47002
+ function ownerTable(el) {
46972
47003
  return ancestors(el).find((a) => a.tag === "table");
46973
47004
  }
46974
47005
  var headersAttrDangling = {
@@ -46981,7 +47012,7 @@ var headersAttrDangling = {
46981
47012
  if (el.tag !== "td" && el.tag !== "th") continue;
46982
47013
  const raw = (attr(el, "headers") ?? "").trim();
46983
47014
  if (!raw || raw.includes("{")) continue;
46984
- const table = ownerTable(el, doc);
47015
+ const table = ownerTable(el);
46985
47016
  if (!table) continue;
46986
47017
  const inTable = new Map(
46987
47018
  descendants(table).filter((d) => d.tag === "th" || d.tag === "td").map((d) => [attr(d, "id") ?? "", d])
@@ -48784,7 +48815,7 @@ function matchNode(el, node, depth) {
48784
48815
  if (node.has || node.lacks) {
48785
48816
  const desc = descendants(el);
48786
48817
  if (node.has && !node.has.every((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
48787
- if (node.lacks && node.lacks.some((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
48818
+ if (node.lacks?.some((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
48788
48819
  }
48789
48820
  return true;
48790
48821
  }
@@ -49000,7 +49031,7 @@ function extractGraphNode(ast, doc, file, opts = {}) {
49000
49031
  if (ast) {
49001
49032
  for (const stmt of asNodes((asNode(ast.program) ?? ast).body)) {
49002
49033
  const vd = stmt.type === "VariableDeclaration" ? stmt : stmt.type === "ExportNamedDeclaration" ? asNode(stmt.declaration) : void 0;
49003
- if (!vd || vd.type !== "VariableDeclaration") continue;
49034
+ if (vd?.type !== "VariableDeclaration") continue;
49004
49035
  for (const d of asNodes(vd.declarations)) {
49005
49036
  const idn = asNode(d.id);
49006
49037
  const init2 = asNode(d.init);
@@ -49353,7 +49384,7 @@ function hasMedia(d) {
49353
49384
  return d.elements.some((el) => {
49354
49385
  if (el.tag === "object" || el.tag === "embed") {
49355
49386
  const type = (el.attribs.type ?? "").toLowerCase();
49356
- return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/") ? true : false;
49387
+ return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/");
49357
49388
  }
49358
49389
  if (el.tag !== "iframe") return false;
49359
49390
  const hay = `${el.attribs.src ?? ""} ${el.attribs.title ?? ""} ${el.attribs.allow ?? ""}`;
@@ -54407,6 +54438,13 @@ function derivePackResults(audit2, packKey, pageId) {
54407
54438
  const overrides = pack.overrides;
54408
54439
  const seen = audit2.scope.subjectsSeen;
54409
54440
  const subjectAbsent = (pc) => seen !== void 0 && subjectsAbsent(subjectsForPackCriterion(packKey, pc.id, pc.wcag), new Set(seen));
54441
+ const ownSubjectSeen = (pc) => {
54442
+ if (seen === void 0) return false;
54443
+ const own = ownPackSubjects(packKey, pc.id);
54444
+ if (!own?.length) return false;
54445
+ const present = new Set(seen);
54446
+ return own.some((id) => present.has(id));
54447
+ };
54410
54448
  const ownRuleIds = new Set((pack.rules ?? []).map((r) => `pack:${packKey}:${r.id}`));
54411
54449
  const deriveBase = (pc) => {
54412
54450
  const outOfScope = pc.wcag.every((sc) => {
@@ -54443,6 +54481,9 @@ function derivePackResults(audit2, packKey, pageId) {
54443
54481
  return { id: pc.id, theme: pc.theme, status: INAPPLICABLE_STATUS, findings, scs: pc.wcag, inapplicable: true };
54444
54482
  }
54445
54483
  const inapplicable = status === INAPPLICABLE_STATUS && (scResults.length === 0 || scResults.every((r) => r.inapplicable));
54484
+ if (inapplicable && ownSubjectSeen(pc)) {
54485
+ return { id: pc.id, theme: pc.theme, status: "manual", findings, scs: pc.wcag };
54486
+ }
54446
54487
  return { id: pc.id, theme: pc.theme, status, findings, scs: pc.wcag, ...inapplicable ? { inapplicable: true } : {} };
54447
54488
  };
54448
54489
  const enabledSecondary = (pack.secondaryMappings ?? []).filter((m) => m.enabled === true);
@@ -59219,7 +59260,7 @@ function applyAdjudication(audit2, adj, opts = {}) {
59219
59260
  if (v === null) {
59220
59261
  blame(it.criteriaId, `criterion ${it.criteriaId}: unadjudicated (verdict is null)`);
59221
59262
  } else if (v === "C" || v === "NA") {
59222
- if (!it.justification || !it.justification.trim()) blame(it.criteriaId, `criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
59263
+ if (!it.justification?.trim()) blame(it.criteriaId, `criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
59223
59264
  const cites = (it.citations ?? []).map(readCitation).filter((c2) => c2 !== null);
59224
59265
  if (v === "C" && it.evidenceComplete === false) {
59225
59266
  blame(
@@ -59272,7 +59313,7 @@ function applyAdjudication(audit2, adj, opts = {}) {
59272
59313
  );
59273
59314
  continue;
59274
59315
  }
59275
- if (!f.normativeRef || !f.normativeRef.trim()) {
59316
+ if (!f.normativeRef?.trim()) {
59276
59317
  blame(it.criteriaId, `criterion ${it.criteriaId}: an NC finding requires a normativeRef citing the failed test of the active standard`);
59277
59318
  } else if (!normativeRefResolves(f.normativeRef, adj.standard, isCore(adj.standard) ? void 0 : it.criteriaId)) {
59278
59319
  blame(
@@ -60873,7 +60914,7 @@ function mergeSnapshotAudit(base, snap) {
60873
60914
  for (const c2 of merged.criteria) {
60874
60915
  if (!c2.inapplicable) continue;
60875
60916
  const measured = snapById.get(c2.id);
60876
- if (!measured || measured.status !== "C" || measured.inapplicable) continue;
60917
+ if (measured?.status !== "C" || measured.inapplicable) continue;
60877
60918
  c2.status = "C";
60878
60919
  delete c2.inapplicable;
60879
60920
  if (measured.justification) c2.justification = measured.justification;
@@ -64916,7 +64957,7 @@ function foldRecordedAdjudication(root, fresh) {
64916
64957
  for (const c2 of prior.criteria) {
64917
64958
  if (c2.decidedBy !== "agent") continue;
64918
64959
  const target = byId2.get(c2.id);
64919
- if (!target || target.status !== "manual") continue;
64960
+ if (target?.status !== "manual") continue;
64920
64961
  target.status = c2.status;
64921
64962
  target.decidedBy = "agent";
64922
64963
  if (c2.justification) target.justification = c2.justification;
@@ -65541,7 +65582,7 @@ For EACH entry:
65541
65582
  Then fold: \`ultra11y verify --apply ${join47(runAbs, "VERIFY.todo.json")} --report <the report .md>\`. Without a shell, leave the fold to whoever gave you the file.
65542
65583
  `;
65543
65584
  }
65544
- function agentContracts(runAbs, engineAbs, opts = {}) {
65585
+ function agentContracts(runAbs, opts = {}) {
65545
65586
  const footer = ONE_WRITER_FOOTER.replaceAll("<RUN>", runAbs);
65546
65587
  if (opts.eco) return { adjudicator: ecoAdjudicatorContract(runAbs), refuter: ecoRefuterContract(runAbs) };
65547
65588
  return {
@@ -65724,7 +65765,7 @@ function orchestrateRun(runDir, engineAbs, opts = {}) {
65724
65765
  mkdirSync17(agentsDir, { recursive: true });
65725
65766
  const written = [];
65726
65767
  const notices = [];
65727
- for (const [name2, content] of Object.entries(agentContracts(run2, engineAbs, { eco: opts.eco === true }))) {
65768
+ for (const [name2, content] of Object.entries(agentContracts(run2, { eco: opts.eco === true }))) {
65728
65769
  const p = join48(agentsDir, `${name2}.md`);
65729
65770
  writeFileSync19(p, content);
65730
65771
  written.push(p);
@@ -66230,7 +66271,7 @@ function bool(v) {
66230
66271
  }
66231
66272
  function strArray2(v) {
66232
66273
  const a = Array.isArray(v) && v.every((x) => typeof x === "string") ? v : void 0;
66233
- return a && a.length ? a : void 0;
66274
+ return a?.length ? a : void 0;
66234
66275
  }
66235
66276
  function positive(v, key) {
66236
66277
  const n = num2(v);
@@ -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.25.0
8
+ version: 5.27.0
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.25.0";
9
+ var VERSION = "5.27.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -36908,7 +36908,7 @@ function makeFilter(opts = {}) {
36908
36908
  const rel2 = toPosix(file);
36909
36909
  if (include && !include(rel2)) return false;
36910
36910
  if (exclude?.(rel2)) return false;
36911
- if (!opts.noDefaultExcludes && isTestArtifact(rel2) && !(include && include(rel2))) return false;
36911
+ if (!opts.noDefaultExcludes && isTestArtifact(rel2) && !include?.(rel2)) return false;
36912
36912
  return true;
36913
36913
  };
36914
36914
  }
@@ -36974,7 +36974,7 @@ function expandInputs(inputs, opts = {}) {
36974
36974
  const before = list.length;
36975
36975
  list = list.filter((f) => {
36976
36976
  const rel2 = toPosix(f);
36977
- return !isTestArtifact(rel2) || explicit.has(f) || include != null && include(rel2);
36977
+ return !isTestArtifact(rel2) || explicit.has(f) || include?.(rel2);
36978
36978
  });
36979
36979
  const dropped = before - list.length;
36980
36980
  if (dropped) opts.onWarn?.(`ultra11y: skipped ${dropped} test/spec/story file(s) \u2014 pass --no-default-excludes to audit them.`);
@@ -45061,14 +45061,42 @@ var SUBJECTS = {
45061
45061
  (e) => h(d, e, `<${e.tag} autoplay=${attr(e, "autoplay") !== void 0} controls=${attr(e, "controls") !== void 0}>`, `media|${e.tag}`)
45062
45062
  )
45063
45063
  ]),
45064
- // A change of context triggered by focus or by changing a value.
45065
- // RGAA 7.4 / WCAG 3.2.1-3.2.2. The handlers are half the subject; the OTHER half is what a
45066
- // script does once it runs, and that is where a context change actually happens: submitting a
45067
- // form, navigating, opening a window. Looking only for onFocus/onBlur/onChange left the
45068
- // criterion with an empty harvest on any codebase that binds its handlers in JavaScript
45069
- // rather than in markup — which is most of them — and an empty harvest is a criterion the
45070
- // adjudicator can only guess at.
45064
+ // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
45065
+ //
45066
+ // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
45067
+ // CONSEQUENCES — onFocus/onBlur/onChange, `.submit(`, `location`, `router.push`,
45068
+ // `window.open` — on the theory that a context change is one of them. It is, but the list can
45069
+ // only ever be the ways we have already seen a script change context, and a criterion whose
45070
+ // subject is « each script » cannot be answered from a catalogue of endings.
45071
+ //
45072
+ // Measured on tests/fixtures/realworld, which ships LoginForm.tsx whose form carries
45073
+ // `onSubmit={(e) => { e.preventDefault(); … }}`: 7.4 reached the adjudicator with population
45074
+ // ZERO. A script was right there, doing the thing the criterion asks about, and the harvest
45075
+ // walked past it because `onSubmit` was not on the list. The adjudicator was told « no
45076
+ // evidence » and billed to guess between NA and manual — on a page it should have been handed
45077
+ // twenty lines of React to read.
45078
+ //
45079
+ // So it collects the script FIRST: any `<script>`, any `on*` binding whatever the event, and
45080
+ // then the consequences it always looked for, which stay because they are what an adjudicator
45081
+ // most needs pointed out. Two things follow. A page with a script always arrives with it, so
45082
+ // the verdict is made on the code rather than on its absence. And a page with no script at all
45083
+ // harvests nothing — which is now a FACT about the code, not a gap in a pattern list, so 7.4
45084
+ // and 3.2.1/3.2.2 close as « not applicable » with no model in the loop (EXISTENCE_SUBJECTS).
45071
45085
  contextChange: (docs) => docs.flatMap((d) => [
45086
+ // The script itself — the subject, before anything it goes on to do.
45087
+ ...elementsByTag(d, "script").map(
45088
+ (e) => h(
45089
+ d,
45090
+ e,
45091
+ `<script${attr(e, "src") ? ` src="${attr(e, "src")}"` : ""}> \u2014 does anything it runs change context, and is the user warned or in control?`,
45092
+ `script|${attr(e, "src") ?? "inline"}`
45093
+ )
45094
+ ),
45095
+ // Any handler binding, whatever the event: `onSubmit` and `onClick` change context as
45096
+ // readily as `onChange`, and a criterion about scripts has no business enumerating events.
45097
+ ...linesOf(d, /\bon[A-Z][A-Za-z]*\s*=|\son[a-z]+\s*=\s*["']/).map(
45098
+ (l) => hAt(d, l.line, "handler", `handler \u2014 does it change context (navigate, submit, open a window, move focus)? ${l.text}`, `handler|${l.text}`)
45099
+ ),
45072
45100
  ...linesOf(d, /\bon(?:Focus|Blur)\s*=/).map(
45073
45101
  (l) => hAt(d, l.line, "handler", `focus handler \u2014 does it change context (navigate, submit, move focus)? ${l.text}`, `onfocus|${l.text}`)
45074
45102
  ),
@@ -45436,9 +45464,12 @@ var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set([
45436
45464
  "autocomplete",
45437
45465
  "errors",
45438
45466
  "frames",
45439
- "downloadDocs"
45467
+ "downloadDocs",
45468
+ "newWindow",
45469
+ "contextChange"
45440
45470
  ]);
45441
45471
  var subjectsForSc = (sc) => SC_SUBJECTS[sc] ?? [];
45472
+ var ownPackSubjects = (standard, id) => PACK_SUBJECTS[standard]?.[id];
45442
45473
  function subjectsForPackCriterion(standard, id, scs) {
45443
45474
  const own = PACK_SUBJECTS[standard]?.[id];
45444
45475
  if (own) return own;
@@ -46968,7 +46999,7 @@ var tableEmptyDataCell = {
46968
46999
  return out2;
46969
47000
  }
46970
47001
  };
46971
- function ownerTable(el, doc) {
47002
+ function ownerTable(el) {
46972
47003
  return ancestors(el).find((a) => a.tag === "table");
46973
47004
  }
46974
47005
  var headersAttrDangling = {
@@ -46981,7 +47012,7 @@ var headersAttrDangling = {
46981
47012
  if (el.tag !== "td" && el.tag !== "th") continue;
46982
47013
  const raw = (attr(el, "headers") ?? "").trim();
46983
47014
  if (!raw || raw.includes("{")) continue;
46984
- const table = ownerTable(el, doc);
47015
+ const table = ownerTable(el);
46985
47016
  if (!table) continue;
46986
47017
  const inTable = new Map(
46987
47018
  descendants(table).filter((d) => d.tag === "th" || d.tag === "td").map((d) => [attr(d, "id") ?? "", d])
@@ -48784,7 +48815,7 @@ function matchNode(el, node, depth) {
48784
48815
  if (node.has || node.lacks) {
48785
48816
  const desc = descendants(el);
48786
48817
  if (node.has && !node.has.every((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
48787
- if (node.lacks && node.lacks.some((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
48818
+ if (node.lacks?.some((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
48788
48819
  }
48789
48820
  return true;
48790
48821
  }
@@ -49000,7 +49031,7 @@ function extractGraphNode(ast, doc, file, opts = {}) {
49000
49031
  if (ast) {
49001
49032
  for (const stmt of asNodes((asNode(ast.program) ?? ast).body)) {
49002
49033
  const vd = stmt.type === "VariableDeclaration" ? stmt : stmt.type === "ExportNamedDeclaration" ? asNode(stmt.declaration) : void 0;
49003
- if (!vd || vd.type !== "VariableDeclaration") continue;
49034
+ if (vd?.type !== "VariableDeclaration") continue;
49004
49035
  for (const d of asNodes(vd.declarations)) {
49005
49036
  const idn = asNode(d.id);
49006
49037
  const init2 = asNode(d.init);
@@ -49353,7 +49384,7 @@ function hasMedia(d) {
49353
49384
  return d.elements.some((el) => {
49354
49385
  if (el.tag === "object" || el.tag === "embed") {
49355
49386
  const type = (el.attribs.type ?? "").toLowerCase();
49356
- return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/") ? true : false;
49387
+ return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/");
49357
49388
  }
49358
49389
  if (el.tag !== "iframe") return false;
49359
49390
  const hay = `${el.attribs.src ?? ""} ${el.attribs.title ?? ""} ${el.attribs.allow ?? ""}`;
@@ -54407,6 +54438,13 @@ function derivePackResults(audit2, packKey, pageId) {
54407
54438
  const overrides = pack.overrides;
54408
54439
  const seen = audit2.scope.subjectsSeen;
54409
54440
  const subjectAbsent = (pc) => seen !== void 0 && subjectsAbsent(subjectsForPackCriterion(packKey, pc.id, pc.wcag), new Set(seen));
54441
+ const ownSubjectSeen = (pc) => {
54442
+ if (seen === void 0) return false;
54443
+ const own = ownPackSubjects(packKey, pc.id);
54444
+ if (!own?.length) return false;
54445
+ const present = new Set(seen);
54446
+ return own.some((id) => present.has(id));
54447
+ };
54410
54448
  const ownRuleIds = new Set((pack.rules ?? []).map((r) => `pack:${packKey}:${r.id}`));
54411
54449
  const deriveBase = (pc) => {
54412
54450
  const outOfScope = pc.wcag.every((sc) => {
@@ -54443,6 +54481,9 @@ function derivePackResults(audit2, packKey, pageId) {
54443
54481
  return { id: pc.id, theme: pc.theme, status: INAPPLICABLE_STATUS, findings, scs: pc.wcag, inapplicable: true };
54444
54482
  }
54445
54483
  const inapplicable = status === INAPPLICABLE_STATUS && (scResults.length === 0 || scResults.every((r) => r.inapplicable));
54484
+ if (inapplicable && ownSubjectSeen(pc)) {
54485
+ return { id: pc.id, theme: pc.theme, status: "manual", findings, scs: pc.wcag };
54486
+ }
54446
54487
  return { id: pc.id, theme: pc.theme, status, findings, scs: pc.wcag, ...inapplicable ? { inapplicable: true } : {} };
54447
54488
  };
54448
54489
  const enabledSecondary = (pack.secondaryMappings ?? []).filter((m) => m.enabled === true);
@@ -59219,7 +59260,7 @@ function applyAdjudication(audit2, adj, opts = {}) {
59219
59260
  if (v === null) {
59220
59261
  blame(it.criteriaId, `criterion ${it.criteriaId}: unadjudicated (verdict is null)`);
59221
59262
  } else if (v === "C" || v === "NA") {
59222
- if (!it.justification || !it.justification.trim()) blame(it.criteriaId, `criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
59263
+ if (!it.justification?.trim()) blame(it.criteriaId, `criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
59223
59264
  const cites = (it.citations ?? []).map(readCitation).filter((c2) => c2 !== null);
59224
59265
  if (v === "C" && it.evidenceComplete === false) {
59225
59266
  blame(
@@ -59272,7 +59313,7 @@ function applyAdjudication(audit2, adj, opts = {}) {
59272
59313
  );
59273
59314
  continue;
59274
59315
  }
59275
- if (!f.normativeRef || !f.normativeRef.trim()) {
59316
+ if (!f.normativeRef?.trim()) {
59276
59317
  blame(it.criteriaId, `criterion ${it.criteriaId}: an NC finding requires a normativeRef citing the failed test of the active standard`);
59277
59318
  } else if (!normativeRefResolves(f.normativeRef, adj.standard, isCore(adj.standard) ? void 0 : it.criteriaId)) {
59278
59319
  blame(
@@ -60873,7 +60914,7 @@ function mergeSnapshotAudit(base, snap) {
60873
60914
  for (const c2 of merged.criteria) {
60874
60915
  if (!c2.inapplicable) continue;
60875
60916
  const measured = snapById.get(c2.id);
60876
- if (!measured || measured.status !== "C" || measured.inapplicable) continue;
60917
+ if (measured?.status !== "C" || measured.inapplicable) continue;
60877
60918
  c2.status = "C";
60878
60919
  delete c2.inapplicable;
60879
60920
  if (measured.justification) c2.justification = measured.justification;
@@ -64916,7 +64957,7 @@ function foldRecordedAdjudication(root, fresh) {
64916
64957
  for (const c2 of prior.criteria) {
64917
64958
  if (c2.decidedBy !== "agent") continue;
64918
64959
  const target = byId2.get(c2.id);
64919
- if (!target || target.status !== "manual") continue;
64960
+ if (target?.status !== "manual") continue;
64920
64961
  target.status = c2.status;
64921
64962
  target.decidedBy = "agent";
64922
64963
  if (c2.justification) target.justification = c2.justification;
@@ -65541,7 +65582,7 @@ For EACH entry:
65541
65582
  Then fold: \`ultra11y verify --apply ${join47(runAbs, "VERIFY.todo.json")} --report <the report .md>\`. Without a shell, leave the fold to whoever gave you the file.
65542
65583
  `;
65543
65584
  }
65544
- function agentContracts(runAbs, engineAbs, opts = {}) {
65585
+ function agentContracts(runAbs, opts = {}) {
65545
65586
  const footer = ONE_WRITER_FOOTER.replaceAll("<RUN>", runAbs);
65546
65587
  if (opts.eco) return { adjudicator: ecoAdjudicatorContract(runAbs), refuter: ecoRefuterContract(runAbs) };
65547
65588
  return {
@@ -65724,7 +65765,7 @@ function orchestrateRun(runDir, engineAbs, opts = {}) {
65724
65765
  mkdirSync17(agentsDir, { recursive: true });
65725
65766
  const written = [];
65726
65767
  const notices = [];
65727
- for (const [name2, content] of Object.entries(agentContracts(run2, engineAbs, { eco: opts.eco === true }))) {
65768
+ for (const [name2, content] of Object.entries(agentContracts(run2, { eco: opts.eco === true }))) {
65728
65769
  const p = join48(agentsDir, `${name2}.md`);
65729
65770
  writeFileSync19(p, content);
65730
65771
  written.push(p);
@@ -66230,7 +66271,7 @@ function bool(v) {
66230
66271
  }
66231
66272
  function strArray2(v) {
66232
66273
  const a = Array.isArray(v) && v.every((x) => typeof x === "string") ? v : void 0;
66233
- return a && a.length ? a : void 0;
66274
+ return a?.length ? a : void 0;
66234
66275
  }
66235
66276
  function positive(v, key) {
66236
66277
  const n = num2(v);
@@ -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.25.0
7
+ version: 5.27.0
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.25.0";
9
+ var VERSION = "5.27.0";
10
10
  var SCHEMA_VERSION = 2;
11
11
  var INAPPLICABLE_STATUS = "C";
12
12
 
@@ -36908,7 +36908,7 @@ function makeFilter(opts = {}) {
36908
36908
  const rel2 = toPosix(file);
36909
36909
  if (include && !include(rel2)) return false;
36910
36910
  if (exclude?.(rel2)) return false;
36911
- if (!opts.noDefaultExcludes && isTestArtifact(rel2) && !(include && include(rel2))) return false;
36911
+ if (!opts.noDefaultExcludes && isTestArtifact(rel2) && !include?.(rel2)) return false;
36912
36912
  return true;
36913
36913
  };
36914
36914
  }
@@ -36974,7 +36974,7 @@ function expandInputs(inputs, opts = {}) {
36974
36974
  const before = list.length;
36975
36975
  list = list.filter((f) => {
36976
36976
  const rel2 = toPosix(f);
36977
- return !isTestArtifact(rel2) || explicit.has(f) || include != null && include(rel2);
36977
+ return !isTestArtifact(rel2) || explicit.has(f) || include?.(rel2);
36978
36978
  });
36979
36979
  const dropped = before - list.length;
36980
36980
  if (dropped) opts.onWarn?.(`ultra11y: skipped ${dropped} test/spec/story file(s) \u2014 pass --no-default-excludes to audit them.`);
@@ -45061,14 +45061,42 @@ var SUBJECTS = {
45061
45061
  (e) => h(d, e, `<${e.tag} autoplay=${attr(e, "autoplay") !== void 0} controls=${attr(e, "controls") !== void 0}>`, `media|${e.tag}`)
45062
45062
  )
45063
45063
  ]),
45064
- // A change of context triggered by focus or by changing a value.
45065
- // RGAA 7.4 / WCAG 3.2.1-3.2.2. The handlers are half the subject; the OTHER half is what a
45066
- // script does once it runs, and that is where a context change actually happens: submitting a
45067
- // form, navigating, opening a window. Looking only for onFocus/onBlur/onChange left the
45068
- // criterion with an empty harvest on any codebase that binds its handlers in JavaScript
45069
- // rather than in markup — which is most of them — and an empty harvest is a criterion the
45070
- // adjudicator can only guess at.
45064
+ // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
45065
+ //
45066
+ // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
45067
+ // CONSEQUENCES — onFocus/onBlur/onChange, `.submit(`, `location`, `router.push`,
45068
+ // `window.open` — on the theory that a context change is one of them. It is, but the list can
45069
+ // only ever be the ways we have already seen a script change context, and a criterion whose
45070
+ // subject is « each script » cannot be answered from a catalogue of endings.
45071
+ //
45072
+ // Measured on tests/fixtures/realworld, which ships LoginForm.tsx whose form carries
45073
+ // `onSubmit={(e) => { e.preventDefault(); … }}`: 7.4 reached the adjudicator with population
45074
+ // ZERO. A script was right there, doing the thing the criterion asks about, and the harvest
45075
+ // walked past it because `onSubmit` was not on the list. The adjudicator was told « no
45076
+ // evidence » and billed to guess between NA and manual — on a page it should have been handed
45077
+ // twenty lines of React to read.
45078
+ //
45079
+ // So it collects the script FIRST: any `<script>`, any `on*` binding whatever the event, and
45080
+ // then the consequences it always looked for, which stay because they are what an adjudicator
45081
+ // most needs pointed out. Two things follow. A page with a script always arrives with it, so
45082
+ // the verdict is made on the code rather than on its absence. And a page with no script at all
45083
+ // harvests nothing — which is now a FACT about the code, not a gap in a pattern list, so 7.4
45084
+ // and 3.2.1/3.2.2 close as « not applicable » with no model in the loop (EXISTENCE_SUBJECTS).
45071
45085
  contextChange: (docs) => docs.flatMap((d) => [
45086
+ // The script itself — the subject, before anything it goes on to do.
45087
+ ...elementsByTag(d, "script").map(
45088
+ (e) => h(
45089
+ d,
45090
+ e,
45091
+ `<script${attr(e, "src") ? ` src="${attr(e, "src")}"` : ""}> \u2014 does anything it runs change context, and is the user warned or in control?`,
45092
+ `script|${attr(e, "src") ?? "inline"}`
45093
+ )
45094
+ ),
45095
+ // Any handler binding, whatever the event: `onSubmit` and `onClick` change context as
45096
+ // readily as `onChange`, and a criterion about scripts has no business enumerating events.
45097
+ ...linesOf(d, /\bon[A-Z][A-Za-z]*\s*=|\son[a-z]+\s*=\s*["']/).map(
45098
+ (l) => hAt(d, l.line, "handler", `handler \u2014 does it change context (navigate, submit, open a window, move focus)? ${l.text}`, `handler|${l.text}`)
45099
+ ),
45072
45100
  ...linesOf(d, /\bon(?:Focus|Blur)\s*=/).map(
45073
45101
  (l) => hAt(d, l.line, "handler", `focus handler \u2014 does it change context (navigate, submit, move focus)? ${l.text}`, `onfocus|${l.text}`)
45074
45102
  ),
@@ -45436,9 +45464,12 @@ var EXISTENCE_SUBJECTS = /* @__PURE__ */ new Set([
45436
45464
  "autocomplete",
45437
45465
  "errors",
45438
45466
  "frames",
45439
- "downloadDocs"
45467
+ "downloadDocs",
45468
+ "newWindow",
45469
+ "contextChange"
45440
45470
  ]);
45441
45471
  var subjectsForSc = (sc) => SC_SUBJECTS[sc] ?? [];
45472
+ var ownPackSubjects = (standard, id) => PACK_SUBJECTS[standard]?.[id];
45442
45473
  function subjectsForPackCriterion(standard, id, scs) {
45443
45474
  const own = PACK_SUBJECTS[standard]?.[id];
45444
45475
  if (own) return own;
@@ -46968,7 +46999,7 @@ var tableEmptyDataCell = {
46968
46999
  return out2;
46969
47000
  }
46970
47001
  };
46971
- function ownerTable(el, doc) {
47002
+ function ownerTable(el) {
46972
47003
  return ancestors(el).find((a) => a.tag === "table");
46973
47004
  }
46974
47005
  var headersAttrDangling = {
@@ -46981,7 +47012,7 @@ var headersAttrDangling = {
46981
47012
  if (el.tag !== "td" && el.tag !== "th") continue;
46982
47013
  const raw = (attr(el, "headers") ?? "").trim();
46983
47014
  if (!raw || raw.includes("{")) continue;
46984
- const table = ownerTable(el, doc);
47015
+ const table = ownerTable(el);
46985
47016
  if (!table) continue;
46986
47017
  const inTable = new Map(
46987
47018
  descendants(table).filter((d) => d.tag === "th" || d.tag === "td").map((d) => [attr(d, "id") ?? "", d])
@@ -48784,7 +48815,7 @@ function matchNode(el, node, depth) {
48784
48815
  if (node.has || node.lacks) {
48785
48816
  const desc = descendants(el);
48786
48817
  if (node.has && !node.has.every((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
48787
- if (node.lacks && node.lacks.some((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
48818
+ if (node.lacks?.some((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
48788
48819
  }
48789
48820
  return true;
48790
48821
  }
@@ -49000,7 +49031,7 @@ function extractGraphNode(ast, doc, file, opts = {}) {
49000
49031
  if (ast) {
49001
49032
  for (const stmt of asNodes((asNode(ast.program) ?? ast).body)) {
49002
49033
  const vd = stmt.type === "VariableDeclaration" ? stmt : stmt.type === "ExportNamedDeclaration" ? asNode(stmt.declaration) : void 0;
49003
- if (!vd || vd.type !== "VariableDeclaration") continue;
49034
+ if (vd?.type !== "VariableDeclaration") continue;
49004
49035
  for (const d of asNodes(vd.declarations)) {
49005
49036
  const idn = asNode(d.id);
49006
49037
  const init2 = asNode(d.init);
@@ -49353,7 +49384,7 @@ function hasMedia(d) {
49353
49384
  return d.elements.some((el) => {
49354
49385
  if (el.tag === "object" || el.tag === "embed") {
49355
49386
  const type = (el.attribs.type ?? "").toLowerCase();
49356
- return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/") ? true : false;
49387
+ return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/");
49357
49388
  }
49358
49389
  if (el.tag !== "iframe") return false;
49359
49390
  const hay = `${el.attribs.src ?? ""} ${el.attribs.title ?? ""} ${el.attribs.allow ?? ""}`;
@@ -54407,6 +54438,13 @@ function derivePackResults(audit2, packKey, pageId) {
54407
54438
  const overrides = pack.overrides;
54408
54439
  const seen = audit2.scope.subjectsSeen;
54409
54440
  const subjectAbsent = (pc) => seen !== void 0 && subjectsAbsent(subjectsForPackCriterion(packKey, pc.id, pc.wcag), new Set(seen));
54441
+ const ownSubjectSeen = (pc) => {
54442
+ if (seen === void 0) return false;
54443
+ const own = ownPackSubjects(packKey, pc.id);
54444
+ if (!own?.length) return false;
54445
+ const present = new Set(seen);
54446
+ return own.some((id) => present.has(id));
54447
+ };
54410
54448
  const ownRuleIds = new Set((pack.rules ?? []).map((r) => `pack:${packKey}:${r.id}`));
54411
54449
  const deriveBase = (pc) => {
54412
54450
  const outOfScope = pc.wcag.every((sc) => {
@@ -54443,6 +54481,9 @@ function derivePackResults(audit2, packKey, pageId) {
54443
54481
  return { id: pc.id, theme: pc.theme, status: INAPPLICABLE_STATUS, findings, scs: pc.wcag, inapplicable: true };
54444
54482
  }
54445
54483
  const inapplicable = status === INAPPLICABLE_STATUS && (scResults.length === 0 || scResults.every((r) => r.inapplicable));
54484
+ if (inapplicable && ownSubjectSeen(pc)) {
54485
+ return { id: pc.id, theme: pc.theme, status: "manual", findings, scs: pc.wcag };
54486
+ }
54446
54487
  return { id: pc.id, theme: pc.theme, status, findings, scs: pc.wcag, ...inapplicable ? { inapplicable: true } : {} };
54447
54488
  };
54448
54489
  const enabledSecondary = (pack.secondaryMappings ?? []).filter((m) => m.enabled === true);
@@ -59219,7 +59260,7 @@ function applyAdjudication(audit2, adj, opts = {}) {
59219
59260
  if (v === null) {
59220
59261
  blame(it.criteriaId, `criterion ${it.criteriaId}: unadjudicated (verdict is null)`);
59221
59262
  } else if (v === "C" || v === "NA") {
59222
- if (!it.justification || !it.justification.trim()) blame(it.criteriaId, `criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
59263
+ if (!it.justification?.trim()) blame(it.criteriaId, `criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
59223
59264
  const cites = (it.citations ?? []).map(readCitation).filter((c2) => c2 !== null);
59224
59265
  if (v === "C" && it.evidenceComplete === false) {
59225
59266
  blame(
@@ -59272,7 +59313,7 @@ function applyAdjudication(audit2, adj, opts = {}) {
59272
59313
  );
59273
59314
  continue;
59274
59315
  }
59275
- if (!f.normativeRef || !f.normativeRef.trim()) {
59316
+ if (!f.normativeRef?.trim()) {
59276
59317
  blame(it.criteriaId, `criterion ${it.criteriaId}: an NC finding requires a normativeRef citing the failed test of the active standard`);
59277
59318
  } else if (!normativeRefResolves(f.normativeRef, adj.standard, isCore(adj.standard) ? void 0 : it.criteriaId)) {
59278
59319
  blame(
@@ -60873,7 +60914,7 @@ function mergeSnapshotAudit(base, snap) {
60873
60914
  for (const c2 of merged.criteria) {
60874
60915
  if (!c2.inapplicable) continue;
60875
60916
  const measured = snapById.get(c2.id);
60876
- if (!measured || measured.status !== "C" || measured.inapplicable) continue;
60917
+ if (measured?.status !== "C" || measured.inapplicable) continue;
60877
60918
  c2.status = "C";
60878
60919
  delete c2.inapplicable;
60879
60920
  if (measured.justification) c2.justification = measured.justification;
@@ -64916,7 +64957,7 @@ function foldRecordedAdjudication(root, fresh) {
64916
64957
  for (const c2 of prior.criteria) {
64917
64958
  if (c2.decidedBy !== "agent") continue;
64918
64959
  const target = byId2.get(c2.id);
64919
- if (!target || target.status !== "manual") continue;
64960
+ if (target?.status !== "manual") continue;
64920
64961
  target.status = c2.status;
64921
64962
  target.decidedBy = "agent";
64922
64963
  if (c2.justification) target.justification = c2.justification;
@@ -65541,7 +65582,7 @@ For EACH entry:
65541
65582
  Then fold: \`ultra11y verify --apply ${join47(runAbs, "VERIFY.todo.json")} --report <the report .md>\`. Without a shell, leave the fold to whoever gave you the file.
65542
65583
  `;
65543
65584
  }
65544
- function agentContracts(runAbs, engineAbs, opts = {}) {
65585
+ function agentContracts(runAbs, opts = {}) {
65545
65586
  const footer = ONE_WRITER_FOOTER.replaceAll("<RUN>", runAbs);
65546
65587
  if (opts.eco) return { adjudicator: ecoAdjudicatorContract(runAbs), refuter: ecoRefuterContract(runAbs) };
65547
65588
  return {
@@ -65724,7 +65765,7 @@ function orchestrateRun(runDir, engineAbs, opts = {}) {
65724
65765
  mkdirSync17(agentsDir, { recursive: true });
65725
65766
  const written = [];
65726
65767
  const notices = [];
65727
- for (const [name2, content] of Object.entries(agentContracts(run2, engineAbs, { eco: opts.eco === true }))) {
65768
+ for (const [name2, content] of Object.entries(agentContracts(run2, { eco: opts.eco === true }))) {
65728
65769
  const p = join48(agentsDir, `${name2}.md`);
65729
65770
  writeFileSync19(p, content);
65730
65771
  written.push(p);
@@ -66230,7 +66271,7 @@ function bool(v) {
66230
66271
  }
66231
66272
  function strArray2(v) {
66232
66273
  const a = Array.isArray(v) && v.every((x) => typeof x === "string") ? v : void 0;
66233
- return a && a.length ? a : void 0;
66274
+ return a?.length ? a : void 0;
66234
66275
  }
66235
66276
  function positive(v, key) {
66236
66277
  const n = num2(v);
@@ -706,15 +706,43 @@ export const SUBJECTS: Record<string, Subject> = {
706
706
  ),
707
707
  ]),
708
708
 
709
- // A change of context triggered by focus or by changing a value.
710
- // RGAA 7.4 / WCAG 3.2.1-3.2.2. The handlers are half the subject; the OTHER half is what a
711
- // script does once it runs, and that is where a context change actually happens: submitting a
712
- // form, navigating, opening a window. Looking only for onFocus/onBlur/onChange left the
713
- // criterion with an empty harvest on any codebase that binds its handlers in JavaScript
714
- // rather than in markup — which is most of them — and an empty harvest is a criterion the
715
- // adjudicator can only guess at.
709
+ // A change of context initiated by a script. RGAA 7.4 / WCAG 3.2.1-3.2.2.
710
+ //
711
+ // THE SUBJECT IS THE SCRIPT, and getting that wrong cost a release. This used to be a list of
712
+ // CONSEQUENCES — onFocus/onBlur/onChange, `.submit(`, `location`, `router.push`,
713
+ // `window.open` — on the theory that a context change is one of them. It is, but the list can
714
+ // only ever be the ways we have already seen a script change context, and a criterion whose
715
+ // subject is « each script » cannot be answered from a catalogue of endings.
716
+ //
717
+ // Measured on tests/fixtures/realworld, which ships LoginForm.tsx whose form carries
718
+ // `onSubmit={(e) => { e.preventDefault(); … }}`: 7.4 reached the adjudicator with population
719
+ // ZERO. A script was right there, doing the thing the criterion asks about, and the harvest
720
+ // walked past it because `onSubmit` was not on the list. The adjudicator was told « no
721
+ // evidence » and billed to guess between NA and manual — on a page it should have been handed
722
+ // twenty lines of React to read.
723
+ //
724
+ // So it collects the script FIRST: any `<script>`, any `on*` binding whatever the event, and
725
+ // then the consequences it always looked for, which stay because they are what an adjudicator
726
+ // most needs pointed out. Two things follow. A page with a script always arrives with it, so
727
+ // the verdict is made on the code rather than on its absence. And a page with no script at all
728
+ // harvests nothing — which is now a FACT about the code, not a gap in a pattern list, so 7.4
729
+ // and 3.2.1/3.2.2 close as « not applicable » with no model in the loop (EXISTENCE_SUBJECTS).
716
730
  contextChange: (docs) =>
717
731
  docs.flatMap((d) => [
732
+ // The script itself — the subject, before anything it goes on to do.
733
+ ...elementsByTag(d, "script").map((e) =>
734
+ h(
735
+ d,
736
+ e,
737
+ `<script${attr(e, "src") ? ` src="${attr(e, "src")}"` : ""}> — does anything it runs change context, and is the user warned or in control?`,
738
+ `script|${attr(e, "src") ?? "inline"}`,
739
+ ),
740
+ ),
741
+ // Any handler binding, whatever the event: `onSubmit` and `onClick` change context as
742
+ // readily as `onChange`, and a criterion about scripts has no business enumerating events.
743
+ ...linesOf(d, /\bon[A-Z][A-Za-z]*\s*=|\son[a-z]+\s*=\s*["']/).map((l) =>
744
+ hAt(d, l.line, "handler", `handler — does it change context (navigate, submit, open a window, move focus)? ${l.text}`, `handler|${l.text}`),
745
+ ),
718
746
  ...linesOf(d, /\bon(?:Focus|Blur)\s*=/).map((l) =>
719
747
  hAt(d, l.line, "handler", `focus handler — does it change context (navigate, submit, move focus)? ${l.text}`, `onfocus|${l.text}`),
720
748
  ),
@@ -1184,6 +1212,38 @@ export const PACK_SUBJECTS: Record<string, Record<string, string[]>> = {
1184
1212
  // about nothing when there is nothing to download. Its recall is not perfect — a document
1185
1213
  // served from an extensionless URL is missed — which is true of every subject here and is why
1186
1214
  // absence is only ever read as « nothing of that kind is in scope », never as conformance.
1215
+ //
1216
+ // `newWindow` earns its place the same way, and it is worth spelling out because the criterion
1217
+ // it closes has a single test. RGAA 13.2.1 asks one thing — « vérifier qu'à l'ouverture du
1218
+ // document, aucune nouvelle fenêtre n'est ouverte » — and a browser has exactly three ways to
1219
+ // do it: a `target` that leaves this browsing context, a `<meta http-equiv="refresh">`, and a
1220
+ // literal `window.open(`. Two are markup and the third survives minification, so a page that
1221
+ // FAILS 13.2 cannot harvest nothing. Its recall is imperfect in the same way every subject here
1222
+ // is — `open` reached through an alias is missed — which is why the absence still reads only as
1223
+ // « nothing of that kind is in scope », never as conformance.
1224
+ //
1225
+ // Measured on tests/fixtures/realworld, crawled and scanned, engine v5.25.0: 13.2 was one of
1226
+ // only two criteria still reaching the adjudicator with `evidenceComplete` and a population of
1227
+ // zero. The engine already knew the answer and was billing a model to restate it — on the run
1228
+ // this comment was written from, both were ruled NA on the first pass with no gate refusal,
1229
+ // which is the definition of a paid turn that decided nothing.
1230
+ //
1231
+ // `contextChange` is here too NOW, and it was not before — the earlier refusal was right about
1232
+ // the rule and wrong about the subject, so it is worth writing down which changed.
1233
+ //
1234
+ // It used to enumerate CONSEQUENCES: `.submit(`, `location.href`, `router.push`, a handful of
1235
+ // handler attributes. Read that way it is a heuristic, its silence is what a failing page looks
1236
+ // like, and admitting it would have bought a cheap NA at the price of a hidden non-conformity.
1237
+ // That refusal stood, and it also let a real defect stand: on tests/fixtures/realworld, 7.4
1238
+ // arrived with population ZERO from a page shipping `onSubmit={(e) => …}` — a script the harvest
1239
+ // simply did not look for.
1240
+ //
1241
+ // The subject is the SCRIPT now, not the endings: any `<script>`, any `on*` binding. RGAA 7.4
1242
+ // asks about « chaque script qui initie un changement de contexte », so a page that FAILS it runs
1243
+ // a script, and a scope with no script anywhere has nothing for the criterion to be about. That
1244
+ // is an element species, and its silence is a fact a reader can check — the same test
1245
+ // `downloadDocs` and `newWindow` pass. The heuristics stay in the harvest as POINTERS for
1246
+ // whoever reads it; they are no longer what decides whether the subject exists.
1187
1247
  export const EXISTENCE_SUBJECTS: ReadonlySet<string> = new Set([
1188
1248
  "images",
1189
1249
  "tables",
@@ -1194,6 +1254,8 @@ export const EXISTENCE_SUBJECTS: ReadonlySet<string> = new Set([
1194
1254
  "errors",
1195
1255
  "frames",
1196
1256
  "downloadDocs",
1257
+ "newWindow",
1258
+ "contextChange",
1197
1259
  ]);
1198
1260
 
1199
1261
  /** The subjects that decide a success criterion. Empty ⇒ the criterion has none declared,
@@ -1204,6 +1266,16 @@ export const subjectsForSc = (sc: string): string[] => SC_SUBJECTS[sc] ?? [];
1204
1266
  * of its mapped success criteria's. The override is what stops RGAA 11.1 (are the fields
1205
1267
  * labelled?) from being handed the page's heading outline because 1.3.1 happens to come
1206
1268
  * first in its `wcag` list. */
1269
+ /** The subjects a pack criterion declares FOR ITSELF, as opposed to the union it inherits from
1270
+ * its WCAG mapping. Undefined when it declares none.
1271
+ *
1272
+ * The distinction decides whether an INHERITED absence may close the criterion. RGAA 13.2 maps
1273
+ * onto WCAG 3.2.1, whose subject is a script; 13.2's own subject is a window opening. On a page
1274
+ * carrying `target="_blank"` and no script at all, 3.2.1 is legitimately « nothing of that kind
1275
+ * in scope » — and 13.2 inherited that closure and published a conformity over an element
1276
+ * nobody had looked at. Its own subject was right there in the harvest. */
1277
+ export const ownPackSubjects = (standard: string, id: string): string[] | undefined => PACK_SUBJECTS[standard]?.[id];
1278
+
1207
1279
  export function subjectsForPackCriterion(standard: string, id: string, scs: string[]): string[] {
1208
1280
  const own = PACK_SUBJECTS[standard]?.[id];
1209
1281
  if (own) return own;
package/src/adjudicate.ts CHANGED
@@ -799,7 +799,7 @@ export function applyAdjudication(
799
799
  if (v === null) {
800
800
  blame(it.criteriaId, `criterion ${it.criteriaId}: unadjudicated (verdict is null)`);
801
801
  } else if (v === "C" || v === "NA") {
802
- if (!it.justification || !it.justification.trim()) blame(it.criteriaId, `criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
802
+ if (!it.justification?.trim()) blame(it.criteriaId, `criterion ${it.criteriaId}: a ${v} verdict requires a justification`);
803
803
  // A clearing verdict is gated exactly like an accusing one. Before this, the only
804
804
  // check was "the justification is a non-empty string", so `"x"` cleared a criterion —
805
805
  // and a model answering C to everything published a conformance nobody had assessed.
@@ -942,7 +942,7 @@ export function applyAdjudication(
942
942
  }
943
943
  // FAIL-CLOSED: every NC finding must cite a precise, resolvable test of the active
944
944
  // standard. A good practice with no normative test is a recommendation, not an NC.
945
- if (!f.normativeRef || !f.normativeRef.trim()) {
945
+ if (!f.normativeRef?.trim()) {
946
946
  blame(it.criteriaId, `criterion ${it.criteriaId}: an NC finding requires a normativeRef citing the failed test of the active standard`);
947
947
  } else if (!normativeRefResolves(f.normativeRef, adj.standard, isCore(adj.standard) ? undefined : it.criteriaId)) {
948
948
  blame(
package/src/audit.ts CHANGED
@@ -184,7 +184,7 @@ function hasMedia(d: Doc): boolean {
184
184
  if (el.tag === "object" || el.tag === "embed") {
185
185
  const type = (el.attribs.type ?? "").toLowerCase();
186
186
  // A declared non-media type rules it out; anything else is uncertain, so it counts.
187
- return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/") ? true : false;
187
+ return !type || type.startsWith("audio/") || type.startsWith("video/") || type.startsWith("application/");
188
188
  }
189
189
  if (el.tag !== "iframe") return false;
190
190
  const hay = `${el.attribs.src ?? ""} ${el.attribs.title ?? ""} ${el.attribs.allow ?? ""}`;
package/src/dev.ts CHANGED
@@ -356,7 +356,7 @@ function foldRecordedAdjudication(root: string, fresh: AuditResult): void {
356
356
  const target = byId.get(c.id);
357
357
  // A criterion the fresh run decided by itself is authoritative: the page moved, and a
358
358
  // stale ruling must never override a live measurement.
359
- if (!target || target.status !== "manual") continue;
359
+ if (target?.status !== "manual") continue;
360
360
  target.status = c.status;
361
361
  target.decidedBy = "agent";
362
362
  if (c.justification) target.justification = c.justification;
package/src/glob.ts CHANGED
@@ -89,7 +89,7 @@ export function makeFilter(opts: ExpandOpts = {}): (file: string) => boolean {
89
89
  if (include && !include(rel)) return false;
90
90
  if (exclude?.(rel)) return false;
91
91
  // Default test-artifact exclusion (re-admitted by an explicit --include match).
92
- if (!opts.noDefaultExcludes && isTestArtifact(rel) && !(include && include(rel))) return false;
92
+ if (!opts.noDefaultExcludes && isTestArtifact(rel) && !include?.(rel)) return false;
93
93
  return true;
94
94
  };
95
95
  }
@@ -197,7 +197,7 @@ export function expandInputs(inputs: string[], opts: ExpandOpts = {}): string[]
197
197
  const before = list.length;
198
198
  list = list.filter((f) => {
199
199
  const rel = toPosix(f);
200
- return !isTestArtifact(rel) || explicit.has(f) || (include != null && include(rel));
200
+ return !isTestArtifact(rel) || explicit.has(f) || include?.(rel);
201
201
  });
202
202
  const dropped = before - list.length;
203
203
  if (dropped) opts.onWarn?.(`ultra11y: skipped ${dropped} test/spec/story file(s) — pass --no-default-excludes to audit them.`);
@@ -225,7 +225,7 @@ export function extractGraphNode(ast: AstFile | null, doc: Doc, file: string, op
225
225
  if (ast) {
226
226
  for (const stmt of asNodes((asNode(ast.program) ?? ast).body)) {
227
227
  const vd = stmt.type === "VariableDeclaration" ? stmt : stmt.type === "ExportNamedDeclaration" ? asNode(stmt.declaration) : undefined;
228
- if (!vd || vd.type !== "VariableDeclaration") continue;
228
+ if (vd?.type !== "VariableDeclaration") continue;
229
229
  for (const d of asNodes(vd.declarations)) {
230
230
  const idn = asNode(d.id);
231
231
  const init = asNode(d.init);
@@ -22,7 +22,7 @@ interface ScreenshotDetails {
22
22
  path: string;
23
23
  }
24
24
 
25
- // biome-ignore lint/suspicious/noExplicitAny: Cypress's `on` is not a dependency of this package
25
+ // `any`: Cypress's `on` is not a dependency of this package.
26
26
  type On = (event: string, handler: any) => void;
27
27
 
28
28
  export interface TaskPayload extends SnapshotPayload {
@@ -14,10 +14,10 @@ import { type CheckOptions, buildPayload, slugify, stayedOnPage } from "./payloa
14
14
 
15
15
  // Cypress's globals are not a dependency of this package; they exist at run time only.
16
16
  declare const Cypress: {
17
- // biome-ignore lint/suspicious/noExplicitAny: Cypress's own command signature
17
+ // `any`: Cypress's own command signature.
18
18
  Commands: { add(name: string, fn: (...args: any[]) => any): void };
19
19
  };
20
- // biome-ignore lint/suspicious/noExplicitAny: same
20
+ // `any` for the same reason.
21
21
  declare const cy: any;
22
22
 
23
23
  export interface CypressCheckOptions extends CheckOptions {
@@ -88,7 +88,7 @@ function fromConfig(): { only?: string[]; limits?: Omit<ProbeTuning, "only"> } {
88
88
  async function runAxe(page: PlaywrightPage, mode: boolean | "auto" | undefined): Promise<{ ran: true; violations: unknown[] } | undefined> {
89
89
  if (mode === false) return undefined;
90
90
  const demanded = mode === true;
91
- // biome-ignore lint/suspicious/noExplicitAny: the builder's shape is axe's, which is not a dependency here
91
+ // `any`: the builder's shape is axe's, which is not a dependency here.
92
92
  let AxeBuilder: any;
93
93
  // PROJECT FIRST, then relative to this module — the same order the Playwright fixture above
94
94
  // and `scan --runtime local` use, and for the same reason: module-relative alone works for an
@@ -204,16 +204,16 @@ export async function checkA11y(page: PlaywrightPage, opts: PlaywrightCheckOptio
204
204
  *
205
205
  * Resolution is lazy and guarded either way, so importing this in a Cypress-only repo
206
206
  * gives `undefined` rather than a crash. */
207
- // biome-ignore lint/suspicious/noExplicitAny: the shape is Playwright's, which is not a dependency here
207
+ // `any`: the shape is Playwright's, which is not a dependency here.
208
208
  export const test: any = (() => {
209
- // biome-ignore lint/suspicious/noExplicitAny: same
209
+ // `any` for the same reason.
210
210
  const load = (from: string): any => createRequire(from)("@playwright/test");
211
211
  for (const from of [`${process.cwd()}/package.json`, import.meta.url]) {
212
212
  try {
213
213
  const base = load(from).test;
214
214
  if (!base?.extend) continue;
215
215
  return base.extend({
216
- // biome-ignore lint/suspicious/noExplicitAny: same
216
+ // `any` for the same reason.
217
217
  ultra11y: async ({ page }: any, use: any) => {
218
218
  await use((opts: PlaywrightCheckOptions) => checkA11y(page, opts));
219
219
  },
@@ -241,7 +241,7 @@ export interface SweepOptions {
241
241
  * `waitForLoadState`, `waitForFunction`, locators — and Playwright is not a dependency of
242
242
  * this package, so the narrow structural shape the collector needs would reject the very
243
243
  * function this option exists to take. Same reasoning as `test` below. */
244
- // biome-ignore lint/suspicious/noExplicitAny: the shape is Playwright's, which is not a dependency here
244
+ // `any`: the shape is Playwright's, which is not a dependency here.
245
245
  settle?: (page: any) => Promise<void> | void;
246
246
  /** Passed to every `checkA11y`. `failOn: false` (the default here) records without
247
247
  * asserting: the durable output of a sweep is the snapshot, not a red test. */
@@ -80,7 +80,7 @@ function bool(v: unknown): boolean {
80
80
 
81
81
  function strArray(v: unknown): string[] | undefined {
82
82
  const a = Array.isArray(v) && v.every((x) => typeof x === "string") ? (v as string[]) : undefined;
83
- return a && a.length ? a : undefined;
83
+ return a?.length ? a : undefined;
84
84
  }
85
85
 
86
86
  function positive(v: unknown, key: string): number | undefined {
@@ -260,7 +260,10 @@ Then fold: \`ultra11y verify --apply ${join(runAbs, "VERIFY.todo.json")} --repor
260
260
  `;
261
261
  }
262
262
 
263
- export function agentContracts(runAbs: string, engineAbs: string, opts: { eco?: boolean } = {}): Record<string, string> {
263
+ // `engineAbs` is deliberately NOT a parameter: the emitted workflow injects `ENGINE` as a
264
+ // constant and the contract refers to it by name, so a second copy of the path here could only
265
+ // ever disagree with the one the agent is actually handed.
266
+ export function agentContracts(runAbs: string, opts: { eco?: boolean } = {}): Record<string, string> {
264
267
  const footer = ONE_WRITER_FOOTER.replaceAll("<RUN>", runAbs);
265
268
  if (opts.eco) return { adjudicator: ecoAdjudicatorContract(runAbs), refuter: ecoRefuterContract(runAbs) };
266
269
  return {
@@ -165,7 +165,7 @@ export function orchestrateRun(runDir: string, engineAbs: string, opts: Orchestr
165
165
  // exists on the sequential path — least of all in CI, where the adjudicator has no shell and
166
166
  // writes a file. Emitting the fan-out text there and then telling an agent to obey it VERBATIM
167
167
  // is two contradictory sets of instructions, which is what a small model breaks on.
168
- for (const [name, content] of Object.entries(agentContracts(run, engineAbs, { eco: opts.eco === true }))) {
168
+ for (const [name, content] of Object.entries(agentContracts(run, { eco: opts.eco === true }))) {
169
169
  const p = join(agentsDir, `${name}.md`);
170
170
  writeFileSync(p, content);
171
171
  written.push(p);
@@ -1,6 +1,6 @@
1
1
  // Theme 1 — Images.
2
2
  import type { Doc, El } from "../parse/html.js";
3
- import { attr, hasAttr, hasBoundAttr, boundAttr, hasDynamicSpread, visibleText, ancestors, descendants } from "../parse/html.js";
3
+ import { attr, hasBoundAttr, boundAttr, hasDynamicSpread, visibleText, ancestors, descendants } from "../parse/html.js";
4
4
  import { accessibleName, isNameExempt } from "../name.js";
5
5
  import type { Rule, RuleFinding } from "./rule.js";
6
6
 
@@ -181,7 +181,7 @@ const tableEmptyDataCell: Rule = {
181
181
  };
182
182
 
183
183
  /** The <table> a cell belongs to (nearest table ancestor), or undefined. */
184
- function ownerTable(el: El, doc: Doc): El | undefined {
184
+ function ownerTable(el: El): El | undefined {
185
185
  return ancestors(el).find((a) => a.tag === "table");
186
186
  }
187
187
 
@@ -198,7 +198,7 @@ const headersAttrDangling: Rule = {
198
198
  if (el.tag !== "td" && el.tag !== "th") continue;
199
199
  const raw = (attr(el, "headers") ?? "").trim();
200
200
  if (!raw || raw.includes("{")) continue;
201
- const table = ownerTable(el, doc);
201
+ const table = ownerTable(el);
202
202
  if (!table) continue;
203
203
  const inTable = new Map(
204
204
  descendants(table)
package/src/scan.ts CHANGED
@@ -852,7 +852,7 @@ export function mergeSnapshotAudit(base: AuditResult, snap: AuditResult): AuditR
852
852
  const measured = snapById.get(c.id);
853
853
  // Only a snapshot that reached `C` WITHOUT the flag has falsified "nothing of that kind
854
854
  // here" — that one looked at the rendered page and found the subject.
855
- if (!measured || measured.status !== "C" || measured.inapplicable) continue;
855
+ if (measured?.status !== "C" || measured.inapplicable) continue;
856
856
  c.status = "C";
857
857
  delete c.inapplicable;
858
858
  // Keep the claim citable: adopt what the measuring half said, rather than leaving a `C`
@@ -2,7 +2,7 @@
2
2
  // presentation-only WCAG view (former src/standard.ts). For each pack criterion, gather
3
3
  // the results of the WCAG SCs it maps to and fold them with the same NC-dominates rule.
4
4
  // Presentation-only: the canonical, gated verdict lives on the WCAG core.
5
- import { subjectsAbsent, subjectsForPackCriterion } from "../adjudicate-subjects.js";
5
+ import { ownPackSubjects, subjectsAbsent, subjectsForPackCriterion } from "../adjudicate-subjects.js";
6
6
  import { coverageFor, criterionMeasuredOn, unionCoverage } from "../coverage.js";
7
7
  import type { AuditResult, CriterionResult, PageCoverage, Status, Finding, Severity } from "../types.js";
8
8
  import { CORE_KEY, loadPack } from "./registry.js";
@@ -264,6 +264,16 @@ export function derivePackResults(audit: AuditResult, packKey: string, pageId?:
264
264
  // scope that read no file.
265
265
  const seen = audit.scope.subjectsSeen;
266
266
  const subjectAbsent = (pc: PackCriterion): boolean => seen !== undefined && subjectsAbsent(subjectsForPackCriterion(packKey, pc.id, pc.wcag), new Set(seen));
267
+ // Did the criterion's OWN subject turn up? Only its own counts: a criterion that declares none
268
+ // has nothing to defend against an inherited closure, and the union it borrows from its WCAG
269
+ // mapping is exactly where that closure came from.
270
+ const ownSubjectSeen = (pc: PackCriterion): boolean => {
271
+ if (seen === undefined) return false;
272
+ const own = ownPackSubjects(packKey, pc.id);
273
+ if (!own?.length) return false;
274
+ const present = new Set(seen);
275
+ return own.some((id) => present.has(id));
276
+ };
267
277
 
268
278
  // Rules the PACK ITSELF brings, by finding id. A criterion whose `appliesTo` names one of
269
279
  // these has an instrument regardless of what its WCAG mapping is worth — which is the whole
@@ -361,6 +371,28 @@ export function derivePackResults(audit: AuditResult, packKey: string, pageId?:
361
371
  return { id: pc.id, theme: pc.theme, status: INAPPLICABLE_STATUS as Status, findings, scs: pc.wcag, inapplicable: true };
362
372
  }
363
373
  const inapplicable = status === INAPPLICABLE_STATUS && (scResults.length === 0 || scResults.every((r) => r.inapplicable));
374
+ // AN INHERITED ABSENCE CANNOT CLOSE A CRITERION WHOSE OWN SUBJECT IS PRESENT.
375
+ //
376
+ // `inapplicable` here means every mapped success criterion came back « nothing of that kind
377
+ // is in scope ». That is a statement about THEIR subject, and a pack criterion frequently
378
+ // asks about a different one — which is the entire reason PACK_SUBJECTS exists. Publishing
379
+ // their absence as our conformity is the precise shape of « conforming because nobody
380
+ // looked », and it is worse than leaving the criterion open: in the deliverable it reads
381
+ // identically to a verified conformity.
382
+ //
383
+ // Measured on RGAA 13.2 (« l'ouverture d'une nouvelle fenêtre ne doit pas être déclenchée
384
+ // sans action de l'utilisateur »), which maps onto WCAG 3.2.1, whose subject is a script. On
385
+ // a page carrying `target="_blank"` and no script, 3.2.1 is honestly inapplicable — and 13.2
386
+ // inherited that and reported « conforme faute de sujet » over a link sitting in its own
387
+ // harvest. It surfaced the day `contextChange` became an existence subject, which is what
388
+ // made 3.2.1 closable at all; the flaw predates that and was merely unreachable.
389
+ //
390
+ // The answer is « undecided », never a conformity: our subject is here and nobody has ruled
391
+ // on it. NC is already excluded above, and a criterion declaring no subject of its own is
392
+ // untouched.
393
+ if (inapplicable && ownSubjectSeen(pc)) {
394
+ return { id: pc.id, theme: pc.theme, status: "manual" as Status, findings, scs: pc.wcag };
395
+ }
364
396
  return { id: pc.id, theme: pc.theme, status, findings, scs: pc.wcag, ...(inapplicable ? { inapplicable: true } : {}) };
365
397
  };
366
398
 
@@ -60,7 +60,7 @@ function matchNode(el: El, node: MatchNode, depth: number): boolean {
60
60
  const desc = descendants(el);
61
61
  if (node.has && !node.has.every((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
62
62
  // `lacks`: NO listed sub-condition may match ANY descendant.
63
- if (node.lacks && node.lacks.some((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
63
+ if (node.lacks?.some((sub) => desc.some((d) => matchNode(d, sub, depth + 1)))) return false;
64
64
  }
65
65
  return true;
66
66
  }
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.25.0";
3
+ export const VERSION = "5.27.0";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,