ultra11y 2.31.2 → 2.31.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/report.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // WCAG-keyed result. Both keep the honest structure: per-guideline/theme synthesis,
5
5
  // non-conformities, conforming + not-applicable lists, and the manual worklist
6
6
  // (never silently C).
7
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
8
8
  import { join, relative } from "node:path";
9
9
  import type { AuditResult, Finding, Lang, Severity, Status } from "./types.js";
10
10
  import { guidelineTitle, scTitle } from "./wcag.js";
@@ -18,10 +18,10 @@ import {
18
18
  CORE,
19
19
  isCore,
20
20
  loadPack,
21
- getCriterion as getPackCriterion,
22
21
  derivePackResults,
23
22
  packCriteriaForFinding,
24
23
  packConformancePct,
24
+ packTestIds,
25
25
  title as packTitle,
26
26
  themeName,
27
27
  type StandardPack,
@@ -56,6 +56,9 @@ const L = {
56
56
  sev: { bloquant: "Bloquant", majeur: "Majeur", mineur: "Mineur" } as Record<Severity, string>,
57
57
  none: "Aucune non-conformité détectée par le moteur statique.",
58
58
  cTitle: "3. Critères conformes (C)",
59
+ cAgentTitle: "Conformes par adjudication de l'agent (jugement, non prouvé par le moteur)",
60
+ cAgentNote:
61
+ "Ces critères ont été tranchés par l'agent IA à partir des évidences citées, non décidés par le moteur déterministe. Ils sont gatés (chaque verdict cite une évidence résolvable) mais restent un jugement : ils ne sont pas comptés dans le taux de réussite automatique ci-dessus.",
59
62
  naTitle: "4. Critères non applicables (NA)",
60
63
  manualTitle: "5. Critères à adjuger (jugement / rendu) — non décidés par le moteur statique",
61
64
  manualWarn:
@@ -65,6 +68,7 @@ const L = {
65
68
  "Générez la worklist : `verify --manual --in <audit.json> --standard <pack> --out <dir>`. Chaque item y porte l'énoncé complet de ses tests, sa note technique, ses cas particuliers, sa guidance et les termes que le référentiel définit.",
66
69
  outOfScope: "Hors périmètre moteur — mappé sur des SC hors WCAG 2.2 AA ; vérification manuelle.",
67
70
  scopedOut: "Les non-conformités WCAG relevées concernent des éléments hors du périmètre de ce critère — à évaluer séparément.",
71
+ judgment: "L’énoncé du critère demande davantage que les CS WCAG auxquels il est rattaché — le moteur n’y a pas répondu, à trancher.",
68
72
  nothing: "Aucun.",
69
73
  dedup: "Dédup",
70
74
  canonical: "fichier(s) canonique(s) audité(s)",
@@ -117,6 +121,9 @@ const L = {
117
121
  sev: { bloquant: "Blocking", majeur: "Major", mineur: "Minor" } as Record<Severity, string>,
118
122
  none: "No non-conformity detected by the static engine.",
119
123
  cTitle: "3. Conforming criteria (C)",
124
+ cAgentTitle: "Conforming by agent adjudication (judgement, not proven by the engine)",
125
+ cAgentNote:
126
+ "These criteria were ruled on by the AI agent from the evidence it cited, not decided by the deterministic engine. They are gated (every verdict cites resolvable evidence) but remain a judgement: they are not counted in the automatic pass rate above.",
120
127
  naTitle: "4. Not-applicable criteria (NA)",
121
128
  manualTitle: "5. Criteria to adjudicate (judgment / rendering) — not decided by the static engine",
122
129
  manualWarn:
@@ -126,6 +133,7 @@ const L = {
126
133
  "Generate the worklist: `verify --manual --in <audit.json> --standard <pack> --out <dir>`. Each item carries the full wording of its tests, its technical note, its particular cases, its guidance and the terms the standard defines.",
127
134
  outOfScope: "Out of engine scope — mapped to SCs outside WCAG 2.2 AA; manual verification.",
128
135
  scopedOut: "The WCAG failures found concern elements outside this criterion's scope — assess separately.",
136
+ judgment: "The criterion asks more than the WCAG SCs it maps to — the engine did not answer it; rule on it.",
129
137
  nothing: "None.",
130
138
  dedup: "Dedup",
131
139
  canonical: "canonical file(s) audited",
@@ -194,6 +202,11 @@ interface Row {
194
202
  status: Status;
195
203
  findings: Finding[];
196
204
  justification?: string;
205
+ // Who decided this criterion. Absent/"engine" = the deterministic engine; "agent" = an
206
+ // adjudication (gated, but a judgement call); "scan" = the rendered tier. Rendered so a
207
+ // conformity the engine PROVED is never presented as the same thing as one an agent
208
+ // RULED — see the split in section 3 and the header rate.
209
+ decidedBy?: "engine" | "agent" | "scan";
197
210
  }
198
211
 
199
212
  interface Group {
@@ -329,16 +342,27 @@ function render(
329
342
  out.push(`- ${nc.length} ${s.ncCount}${adv.length ? ` · ${adv.length} ${s.advCount}` : ""}`);
330
343
  const notes = pageScope.find((x) => x.id === pg.id)?.notes;
331
344
  if (notes) out.push(`- _${notes}_`);
332
- // The screenshot the snapshot already holds. Referenced relative to the report's own
333
- // directory — a copy would double the artefact's weight for no gain.
345
+ // The screenshot the snapshot already holds. Copied next to the report when there is
346
+ // an output directory, so the directory travels intact — CI uploads `audits/` alone,
347
+ // and a `../.ultra11y/…` reference is a broken image in the artifact the reviewer
348
+ // opens. Without an --out (stdout), stay relative: there is nothing to be
349
+ // self-contained about.
334
350
  const shot = join(PAGES_DIR, pg.id, "screen.png");
335
- if (existsSync(shot))
336
- out.push(
337
- "",
338
- `![${s.screenshotAlt(pg.name)}](${relative(opts.outDir ?? ".", shot)
339
- .split("\\")
340
- .join("/")})`,
341
- );
351
+ if (existsSync(shot)) {
352
+ let href = relative(opts.outDir ?? ".", shot)
353
+ .split("\\")
354
+ .join("/");
355
+ if (opts.outDir) {
356
+ try {
357
+ mkdirSync(join(opts.outDir, "assets"), { recursive: true });
358
+ copyFileSync(shot, join(opts.outDir, "assets", `${pg.id}.png`));
359
+ href = `./assets/${pg.id}.png`;
360
+ } catch {
361
+ // Unwritable output dir: keep the relative reference rather than lose the image.
362
+ }
363
+ }
364
+ out.push("", `![${s.screenshotAlt(pg.name)}](${href})`);
365
+ }
342
366
  for (const f of nc.slice(0, 30)) {
343
367
  const crits = pack ? packCriteriaForFinding(pack, f) : [];
344
368
  const label = crits.length ? crits.join(", ") : f.criteriaId; // graceful fallback to the WCAG SC
@@ -348,10 +372,23 @@ function render(
348
372
  }
349
373
  }
350
374
 
351
- // 3. conforming
375
+ // 3. conforming — split by PROVENANCE. A criterion the deterministic engine decided and
376
+ // one an agent ruled on are both "C", but they are not the same claim, and merging them
377
+ // into one list (and one headline rate) is how an adjudication pass could publish dozens
378
+ // of conformities nobody had verified. The gate makes an agent C evidence-bound; this
379
+ // keeps it legible as an agent's judgement all the way to the reader.
352
380
  out.push(`## ${s.cTitle}`, "");
353
381
  const conform = rows.filter((x) => x.status === "C");
354
- out.push(conform.length ? conform.map((x) => `- ${x.label}`).join("\n") : s.nothing, "");
382
+ const byEngine = conform.filter((x) => x.decidedBy !== "agent");
383
+ const byAgent = conform.filter((x) => x.decidedBy === "agent");
384
+ if (!conform.length) out.push(s.nothing, "");
385
+ else {
386
+ if (byEngine.length) out.push(...byEngine.map((x) => `- ${x.label}`), "");
387
+ if (byAgent.length) {
388
+ out.push(`### ${s.cAgentTitle}`, "", `> ${s.cAgentNote}`, "");
389
+ out.push(...byAgent.map((x) => `- ${x.label}${x.justification ? ` — _${x.justification}_` : ""}`), "");
390
+ }
391
+ }
355
392
 
356
393
  // 4. not applicable
357
394
  out.push(`## ${s.naTitle}`, "");
@@ -368,8 +405,8 @@ function render(
368
405
  else {
369
406
  const pack5 = isCore(opts.standard) ? undefined : loadPack(opts.standard);
370
407
  for (const x of manual) {
371
- const tests = pack5 ? Object.keys(getPackCriterion(pack5, x.id)?.tests ?? {}) : [];
372
- const testRef = tests.length ? ` — ${s.testsToRule}: ${tests.map((k) => `\`${x.id}.${k}\``).join(" · ")}` : "";
408
+ const tests = pack5 ? packTestIds(pack5, x.id) : [];
409
+ const testRef = tests.length ? ` — ${s.testsToRule}: ${tests.map((t) => `\`${t}\``).join(" · ")}` : "";
373
410
  out.push(`- ${x.label}${x.justification ? ` — _${x.justification}_` : ""}${testRef}`);
374
411
  }
375
412
  out.push("", `> ${s.manualHowTo}`, "");
@@ -384,7 +421,14 @@ export function renderReport(r: AuditResult, lang: Lang = "en", outDir?: string)
384
421
  const byGuideline = new Map<string, Row[]>();
385
422
  for (const c of r.criteria) {
386
423
  const title = scTitle(c.id, lang);
387
- const row: Row = { id: c.id, label: title ? `${c.id} — ${title}` : c.id, status: c.status, findings: c.findings, justification: c.justification };
424
+ const row: Row = {
425
+ id: c.id,
426
+ label: title ? `${c.id} — ${title}` : c.id,
427
+ status: c.status,
428
+ findings: c.findings,
429
+ justification: c.justification,
430
+ decidedBy: c.decidedBy,
431
+ };
388
432
  (byGuideline.get(c.guideline) ?? byGuideline.set(c.guideline, []).get(c.guideline)!).push(row);
389
433
  }
390
434
  // `g.title` on the AuditResult's GuidelineTally is the baked-in English title (kept for
@@ -409,15 +453,18 @@ export function renderPackReport(r: AuditResult, pack: StandardPack, lang: Lang
409
453
  label: `${pack.name} ${pr.id} — ${packTitle(pack, pc, lang)}`,
410
454
  status: pr.status,
411
455
  findings: pr.findings,
456
+ ...(pr.decidedBy ? { decidedBy: pr.decidedBy } : {}),
412
457
  // outOfScope / scopedOut criteria are "manual" (not NA) with their own dedicated
413
458
  // justification — never mixed with the ordinary NA reason (see the manual section above).
414
459
  ...(pr.outOfScope
415
460
  ? { justification: s.outOfScope }
416
461
  : pr.scopedOut
417
462
  ? { justification: s.scopedOut }
418
- : pr.status === "NA"
419
- ? { justification: naReason }
420
- : {}),
463
+ : pr.judgment
464
+ ? { justification: s.judgment }
465
+ : pr.status === "NA"
466
+ ? { justification: naReason }
467
+ : {}),
421
468
  };
422
469
  (byTheme.get(pr.theme) ?? byTheme.set(pr.theme, []).get(pr.theme)!).push(row);
423
470
  }
@@ -434,6 +481,10 @@ export function renderPackReport(r: AuditResult, pack: StandardPack, lang: Lang
434
481
  standard: pack.key,
435
482
  partialAudit: untestedNeedsRendering(r),
436
483
  headerRatePct: packConformancePct(derived),
484
+ // Forwarded, unlike before: without it the per-page screenshots resolved against the
485
+ // CWD instead of the report's own directory, so a pack report written to `audits/`
486
+ // carried links that only worked when read from the repo root.
487
+ outDir,
437
488
  });
438
489
  }
439
490
 
@@ -23,6 +23,16 @@ export interface PackCriterionResult {
23
23
  // sibling's failure. It derives as `manual` (assess separately) with a dedicated
24
24
  // scoped-out justification (see src/report.ts renderPackReport).
25
25
  scopedOut?: boolean;
26
+ // Set when the pack marks this criterion `judgment` and the projection WOULD have
27
+ // inherited a `C` from a mapped SC that never answered the criterion's own question
28
+ // (RGAA 8.6 "is the title pertinent?" off WCAG 2.4.2 "a title exists"). It derives
29
+ // `manual` instead — see `judgmentGuard` and src/report.ts for the justification.
30
+ judgment?: boolean;
31
+ // Recorded on the adjudicated branch below (an agent verdict at the pack's own
32
+ // granularity wins over the derivation). Declared here because renderings distinguish a
33
+ // conformity the engine PROVED from one an agent RULED — they are not the same claim.
34
+ justification?: string;
35
+ decidedBy?: "engine" | "agent" | "scan";
26
36
  }
27
37
 
28
38
  // NC dominates (a real failure anywhere fails the criterion); then a decided C; then
@@ -68,7 +78,11 @@ export function packCriteriaForFinding(pack: StandardPack, finding: Finding): st
68
78
  * same as core. Exists so a pack report's header rate can't drift from its own table once
69
79
  * pack overrides (advisory/severity flips) make the two diverge from core `conformancePct`. */
70
80
  export function packConformancePct(derived: PackCriterionResult[]): number {
71
- const c = derived.filter((d) => d.status === "C").length;
81
+ // Agent-adjudicated conformities are deliberately excluded, mirroring the core
82
+ // (`recomputeTallies` in src/adjudicate.ts): this number is the AUTOMATIC pass rate, and
83
+ // a judgement — however well gated — is not an automatic verification. An agent NC still
84
+ // counts; lowering the rate off evidenced findings is the safe direction.
85
+ const c = derived.filter((d) => d.status === "C" && d.decidedBy !== "agent").length;
72
86
  const nc = derived.filter((d) => d.status === "NC").length;
73
87
  return c + nc === 0 ? 100 : Math.round((c / (c + nc)) * 100);
74
88
  }
@@ -123,6 +137,23 @@ function applySecondaryMappings(
123
137
  return { ...base, findings };
124
138
  }
125
139
 
140
+ /** A criterion the pack marks `judgment` never INHERITS a `C` from its mapped WCAG SCs.
141
+ *
142
+ * The projection folds the SCs DINUM's own crosswalk cites, and `aggregate` returns `C`
143
+ * as soon as one of them is `C`. That is the right answer when the RGAA question and the
144
+ * SC ask the same thing, and a fabricated conformity when the RGAA wording asks more:
145
+ * RGAA 8.6 wants a *pertinent* page title, WCAG 2.4.2 only a present one; RGAA 13.3 wants
146
+ * an accessible version of a downloaded document, and no mapped SC ever opened it. Those
147
+ * derived `C`s were the one error this tool must not make, so they become `manual` and go
148
+ * to the agent instead.
149
+ *
150
+ * Only `C` is intercepted: an `NC` was evidenced by a rule that really fired on this
151
+ * criterion, and an `NA` means nothing in scope is concerned — both stay. */
152
+ function judgmentGuard(r: PackCriterionResult, pc: PackCriterion): PackCriterionResult {
153
+ if (!pc.judgment || r.status !== "C") return r;
154
+ return { ...r, status: "manual" as Status, judgment: true };
155
+ }
156
+
126
157
  export function derivePackResults(audit: AuditResult, packKey: string): PackCriterionResult[] {
127
158
  const pack = loadPack(packKey);
128
159
  const byScId = new Map(audit.criteria.map((c) => [c.id, c]));
@@ -207,7 +238,7 @@ export function derivePackResults(audit: AuditResult, packKey: string): PackCrit
207
238
  decidedBy: "agent" as const,
208
239
  };
209
240
  }
210
- const base = deriveBase(pc);
241
+ const base = judgmentGuard(deriveBase(pc), pc);
211
242
  return enabledSecondary.length ? applySecondaryMappings(base, pc, enabledSecondary, secondarySources, pack.defaultLocale) : base;
212
243
  });
213
244
  }
@@ -41,6 +41,19 @@ export function titlePlain(pack: StandardPack, c: PackCriterion, lang: Lang): st
41
41
  return localize(pack, c.titlePlain, lang);
42
42
  }
43
43
 
44
+ /** The criterion's own numbered test ids, in pack order: RGAA 11.1 → ["11.1.1", "11.1.2",
45
+ * "11.1.3"]. These are what an auditor actually rules on, and what a `normativeRef` must
46
+ * cite, so several surfaces render them (the auditor block, the report's to-rule-on list,
47
+ * the PRD, the adjudication worklist, the per-page grid). Centralised here so the id shape
48
+ * is defined once — it used to be re-derived inline at each call site.
49
+ *
50
+ * Callers render these inside backticks, never followed by an em dash: `check`'s criterion
51
+ * scanner matches `(\d+\.\d+)\s*—` and would capture "2.1" out of "6.2.1 — …". */
52
+ export function packTestIds(pack: StandardPack, id: string): string[] {
53
+ const tests = getCriterion(pack, id)?.tests;
54
+ return tests ? Object.keys(tests).map((k) => `${id}.${k}`) : [];
55
+ }
56
+
44
57
  export function resolveGlossary(packKey: string, anchor: string): GlossaryEntry | undefined {
45
58
  return packGlossary(packKey)?.[anchor];
46
59
  }
@@ -37,6 +37,19 @@ export interface PackCriterion {
37
37
  // failure). Optional/additive: a pack WITHOUT `appliesTo` keeps the legacy fan-out
38
38
  // (every mapped SC's findings attach) so third-party packs are unaffected.
39
39
  appliesTo?: { ruleIds: string[] };
40
+ // The criterion's own wording asks MORE than the WCAG SCs it maps to, so a `C` on those
41
+ // SCs is not an answer to it. RGAA 8.6 asks whether the page title is *pertinent*; WCAG
42
+ // 2.4.2 only that a title exists. RGAA 13.3 asks whether a downloadable document has an
43
+ // accessible version; no mapped SC ever opened the document. Without this flag the
44
+ // projection returns `C` as soon as one mapped SC is `C` (see `aggregate` in derive.ts)
45
+ // and publishes a conformity nobody assessed.
46
+ //
47
+ // A flagged criterion can still derive `NC` (a rule actually fired on it) and `NA`
48
+ // (nothing applicable in scope); it simply never inherits a `C` — it derives `manual`
49
+ // and goes to the agent, who rules on it against the criterion's own numbered tests.
50
+ // `appliesTo` already carries the mirror-image promise for NC ("never NC from a
51
+ // sibling's failure"); this is the same guarantee on the conforming side.
52
+ judgment?: boolean;
40
53
  }
41
54
 
42
55
  // The localized DISPLAY vocabulary a standard uses when its audit is rendered for an
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 = "2.31.2";
3
+ export const VERSION = "2.31.3";
4
4
  export const SCHEMA_VERSION = 2;
5
5
 
6
6
  // Lang = the UI FRAME's language — the `L` localization tables each module (report,