ultra11y 4.1.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.opencode/plugins/ultra11y.js +1 -1
- package/README.md +13 -7
- package/package.json +1 -1
- package/scripts/ultra11y.mjs +99 -58
- package/skills/review-a11y/SKILL.md +1 -1
- package/skills/review-a11y/scripts/ultra11y.mjs +99 -58
- package/skills/ultra11y/SKILL.md +1 -1
- package/skills/ultra11y/references/ci.md +33 -11
- package/skills/ultra11y/references/pages.md +28 -9
- package/skills/ultra11y/scripts/ultra11y.mjs +99 -58
- package/src/cli.ts +52 -24
- package/src/evidence.ts +72 -37
- package/src/html-emit.ts +20 -3
- package/src/html-report.ts +27 -4
- package/src/pages-report.ts +10 -4
- package/src/report.ts +16 -7
- package/src/tickets/providers/github.ts +9 -4
- package/src/types.ts +1 -1
package/src/evidence.ts
CHANGED
|
@@ -20,10 +20,18 @@
|
|
|
20
20
|
// engine refuses. Deriving re-runs the same verified join every time, and refuses wholesale
|
|
21
21
|
// when it does not hold, exactly as `align()` already does for styles and boxes.
|
|
22
22
|
//
|
|
23
|
-
// WHAT IT REFUSES
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
23
|
+
// WHAT IT REFUSES, AND WHAT IT NEVER COUNTED. Two different things, and conflating them is
|
|
24
|
+
// how a report starts lying about its own coverage.
|
|
25
|
+
//
|
|
26
|
+
// NOT COUNTED: a finding raised on source code. It is not an occurrence this tier failed to
|
|
27
|
+
// draw, it is an occurrence outside the tier — so it never enters `located`, and a repository
|
|
28
|
+
// audited from source alone gets NO notice rather than one line per finding saying pixels
|
|
29
|
+
// were never involved. The filter is the snapshot PATH (`byPage` below).
|
|
30
|
+
//
|
|
31
|
+
// REFUSED: a finding that IS on a captured page and still gets no crop. Each of those has a
|
|
32
|
+
// reason, recorded and reported rather than left as a gap — a page-scope rule points at
|
|
33
|
+
// `<html>` and would "highlight" the whole screenshot, a component capture rendered by jsdom
|
|
34
|
+
// paints nothing, and — the one that surprises people — an element below the fold is simply
|
|
27
35
|
// not in the image, because every producer captures the VIEWPORT on purpose so the screenshot
|
|
28
36
|
// shares boxes.json's coordinate system (src/integrations/playwright.ts). None of these is a
|
|
29
37
|
// bug to work around; each is a fact to state.
|
|
@@ -39,20 +47,28 @@ import type { SnapshotMeta, SnapshotViewport } from "./snapshot.js";
|
|
|
39
47
|
import type { AuditResult, BoxDigest, BoxEntry, Finding, Lang } from "./types.js";
|
|
40
48
|
|
|
41
49
|
/** Why a finding got no picture. Every one of these is REPORTED, never swallowed: a reader
|
|
42
|
-
* who sees fewer images than non-conformities must be told which, and why.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
* who sees fewer images than non-conformities must be told which, and why.
|
|
51
|
+
*
|
|
52
|
+
* A VALUE, not only a type, so the documentation can be held against it: `references/pages.md`
|
|
53
|
+
* publishes this list to auditors, and a reason added here without a row there is a refusal
|
|
54
|
+
* the reader meets in a report and cannot look up (tests/skill-md.test.ts). */
|
|
55
|
+
export const EVIDENCE_SKIPS = [
|
|
56
|
+
"no-snapshot", // the snapshot this finding's own path names is not on disk (audit rendered elsewhere, `.ultra11y/` cleaned)
|
|
57
|
+
"no-screenshot", // the producer captured no image for this page
|
|
58
|
+
"unreadable-image", // screen.png is not 8-bit truecolour, or is corrupt
|
|
59
|
+
"no-boxes", // no boxes.json, or it did not verify against this dom.html
|
|
60
|
+
"truncated", // the collector hit its element cap; this element has no box
|
|
61
|
+
"no-offsets", // the finding carries no sourceStart (cannot be joined)
|
|
62
|
+
"unjoinable", // sourceStart matches no element in dom.html
|
|
63
|
+
"page-scope", // anchored at the document element: the page screenshot IS the illustration
|
|
64
|
+
"zero-area", // the element has no painted box (display:contents, empty inline)
|
|
65
|
+
"below-the-fold", // outside a viewport-only capture
|
|
66
|
+
"unknown-scale", // image-pixels-per-CSS-pixel is indeterminate
|
|
67
|
+
"deduplicated", // the same (rule, element) is already illustrated — one picture, every occurrence
|
|
68
|
+
"capped", // a numeric limit ran out (DEFAULT_CAPS below) — a DISTINCT defect went undrawn
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
export type EvidenceSkip = (typeof EVIDENCE_SKIPS)[number];
|
|
56
72
|
|
|
57
73
|
export interface EvidenceGeometry {
|
|
58
74
|
/** Context kept around the element, in CSS pixels. */
|
|
@@ -323,7 +339,7 @@ const S: Record<Lang, { alt: (sel: string, page: string) => string; notImaged: (
|
|
|
323
339
|
alt: (sel, page) => `Capture recadrée de l'élément ${sel} sur la page ${page}, entouré d'un cadre`,
|
|
324
340
|
notImaged: (n) => `${n} occurrence(s) ne sont pas illustrées :`,
|
|
325
341
|
reasons: {
|
|
326
|
-
"no-snapshot": "
|
|
342
|
+
"no-snapshot": "la capture de page que ce constat désigne n'est pas sur ce disque — sa référence fichier:ligne reste valable",
|
|
327
343
|
"no-screenshot": "le producteur n'a pas fourni de capture pour cette page, donc le niveau pixel est inactif ici",
|
|
328
344
|
"unreadable-image": "la capture n'est pas décodable (le moteur lit le truecolour 8 bits uniquement) — mieux vaut aucune image qu'une fausse",
|
|
329
345
|
"no-boxes":
|
|
@@ -335,14 +351,15 @@ const S: Record<Lang, { alt: (sel: string, page: string) => string; notImaged: (
|
|
|
335
351
|
"zero-area": "l'élément n'occupe aucune surface peinte",
|
|
336
352
|
"below-the-fold": "l'élément est hors de la capture (la capture couvre la fenêtre, pas la page entière) — sa référence fichier:ligne reste valable",
|
|
337
353
|
"unknown-scale": "le rapport pixels/CSS est indéterminé pour cette page ; un rapport deviné encadrerait le mauvais endroit",
|
|
338
|
-
|
|
354
|
+
deduplicated: "même défaut sur le même élément : il est montré par l'illustration d'une autre occurrence, rien n'a été perdu",
|
|
355
|
+
capped: "défaut distinct laissé sans image : le plafond de vignettes de ce lot est atteint — relevez `--evidence-max` pour les obtenir",
|
|
339
356
|
},
|
|
340
357
|
},
|
|
341
358
|
en: {
|
|
342
359
|
alt: (sel, page) => `Cropped screenshot of the ${sel} element on the ${page} page, outlined`,
|
|
343
360
|
notImaged: (n) => `${n} occurrence(s) are not illustrated:`,
|
|
344
361
|
reasons: {
|
|
345
|
-
"no-snapshot": "
|
|
362
|
+
"no-snapshot": "the page snapshot this finding names is not on this disk — its file:line reference still holds",
|
|
346
363
|
"no-screenshot": "the producer supplied no screenshot for this page, so the pixel tier is inactive here",
|
|
347
364
|
"unreadable-image": "the screenshot does not decode (the engine reads 8-bit truecolour only) — no image beats a wrong one",
|
|
348
365
|
"no-boxes": "the element positions do not verify against the serialized DOM, so they were refused wholesale rather than outlining the wrong element",
|
|
@@ -353,25 +370,36 @@ const S: Record<Lang, { alt: (sel: string, page: string) => string; notImaged: (
|
|
|
353
370
|
"zero-area": "the element paints no box",
|
|
354
371
|
"below-the-fold": "the element sits outside the capture (the screenshot covers the viewport, not the whole page) — its file:line reference still holds",
|
|
355
372
|
"unknown-scale": "the image-pixel to CSS-pixel ratio is indeterminate for this page; a guessed ratio would outline the wrong place",
|
|
356
|
-
|
|
373
|
+
deduplicated: "the same defect on the same element: it is shown by the illustration of another occurrence, nothing was lost",
|
|
374
|
+
capped: "a distinct defect left undrawn: this run's crop limit was reached — raise `--evidence-max` to get them",
|
|
357
375
|
},
|
|
358
376
|
},
|
|
359
377
|
};
|
|
360
378
|
|
|
361
|
-
/**
|
|
362
|
-
*
|
|
363
|
-
|
|
379
|
+
/** The refusals themselves — per page, or overall when `pageId` is null. Undefined when
|
|
380
|
+
* everything located was imaged.
|
|
381
|
+
*
|
|
382
|
+
* This is the DECISION (which refusals, with what counts, in what order); the bullet
|
|
383
|
+
* characters belong to whoever renders it. Markdown gets `- `, the HTML tier gets `<li>`,
|
|
384
|
+
* and neither can drift from the other because there is only one list here. */
|
|
385
|
+
export function evidenceRefusals(m: EvidenceManifest, pageId: string | null, lang: Lang): { headline: string; reasons: string[] } | undefined {
|
|
364
386
|
const t = pageId === null ? m.totals : m.perPage.get(pageId);
|
|
365
|
-
if (!t) return
|
|
387
|
+
if (!t) return undefined;
|
|
366
388
|
const missing = t.located - t.imaged;
|
|
367
|
-
if (missing <= 0) return
|
|
389
|
+
if (missing <= 0) return undefined;
|
|
390
|
+
const entries = Object.entries(t.skipped).filter(([, n]) => n) as [EvidenceSkip, number][];
|
|
368
391
|
const s = S[lang];
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
392
|
+
return {
|
|
393
|
+
headline: s.notImaged(missing),
|
|
394
|
+
reasons: entries.sort((a, b) => b[1] - a[1]).map(([reason, n]) => `${n} — ${s.reasons[reason]}`),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** What the report says about what it did NOT draw, as Markdown. Empty when there is
|
|
399
|
+
* nothing to say. */
|
|
400
|
+
export function evidenceNotice(m: EvidenceManifest, pageId: string | null, lang: Lang): string[] {
|
|
401
|
+
const r = evidenceRefusals(m, pageId, lang);
|
|
402
|
+
return r ? [r.headline, ...r.reasons.map((x) => `- ${x}`)] : [];
|
|
375
403
|
}
|
|
376
404
|
|
|
377
405
|
// ---- the run -----------------------------------------------------------------------------
|
|
@@ -441,10 +469,17 @@ export function writeEvidence(result: AuditResult, opts: EvidenceOptions): Evide
|
|
|
441
469
|
// handful of DIFFERENT things actually wrong.
|
|
442
470
|
const key = `${f.ruleId}\u0000${f.selectorHint}`;
|
|
443
471
|
const ruleCount = perRule.get(f.ruleId) ?? 0;
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
472
|
+
// TWO FACTS, never one label. `deduplicated` means the reader IS looking at this defect,
|
|
473
|
+
// in another occurrence's picture — nothing was lost. `capped` means a DISTINCT defect
|
|
474
|
+
// has no picture at all because a limit ran out. Only the second costs the reader
|
|
475
|
+
// something, and only the second is worth a setting; folding them together hid the one
|
|
476
|
+
// sentence that could tell someone their artifact is incomplete.
|
|
477
|
+
const overLimit = ruleCount >= caps.perRule || onPage >= caps.perPage || manifest.crops.size >= caps.total;
|
|
478
|
+
const refusal = seen.has(key) ? "deduplicated" : overLimit ? "capped" : null;
|
|
479
|
+
if (refusal) {
|
|
480
|
+
manifest.skipped.set(id, refusal);
|
|
481
|
+
bump(tally, refusal);
|
|
482
|
+
bump(manifest.totals, refusal);
|
|
448
483
|
continue;
|
|
449
484
|
}
|
|
450
485
|
|
package/src/html-emit.ts
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { findingId } from "./baseline.js";
|
|
15
|
-
import type
|
|
15
|
+
import { evidenceRefusals, type EvidenceManifest } from "./evidence.js";
|
|
16
16
|
import { renderHtmlDocument, type Doc, type Run } from "./html.js";
|
|
17
|
-
import { compositeDoc, indexDoc, pageDoc, pagesIndexDoc, type CropLookup } from "./html-report.js";
|
|
17
|
+
import { compositeDoc, indexDoc, pageDoc, pagesIndexDoc, type CropLookup, type RefusalLookup } from "./html-report.js";
|
|
18
18
|
import { derivePages, pagesOf } from "./pages.js";
|
|
19
19
|
import { CORE, type StandardId, isCore, loadPack } from "./standards/index.js";
|
|
20
20
|
import type { AuditResult, Finding, Lang } from "./types.js";
|
|
@@ -122,6 +122,15 @@ function cropLookup(m: EvidenceManifest | undefined, lang: Lang, hrefOf: (path:
|
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/** What the tier refused to draw, keyed the way the report tier wants it. Independent of the
|
|
126
|
+
* budget ladder on purpose: the ladder's own notices say which pictures were dropped FOR
|
|
127
|
+
* SIZE, this says which occurrences have no picture AT ALL. Both belong in the document, and
|
|
128
|
+
* neither substitutes for the other. */
|
|
129
|
+
function refusalLookup(m: EvidenceManifest | undefined, lang: Lang): RefusalLookup | undefined {
|
|
130
|
+
if (!m) return undefined;
|
|
131
|
+
return (pageId) => evidenceRefusals(m, pageId, lang);
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
/** Which rung the evidence lands on for a given budget. Pure — the sizes are measured once
|
|
126
135
|
* and the decision is taken before a single byte is encoded. */
|
|
127
136
|
export function pickRung(cropBytes: number[], shotBytes: number[], budget: number): { steps: DegradeStep[]; cropsPerCriterion: number; shots: boolean } {
|
|
@@ -202,9 +211,16 @@ export function writeHtml(result: AuditResult, opts: EmitOpts): EmitResult {
|
|
|
202
211
|
// ONE composite per artifact. `report --html` owns it; `pages --html` writes the index and
|
|
203
212
|
// its sheets beside the Markdown they mirror. Emitting one from both would put two copies of
|
|
204
213
|
// every inlined crop into the same upload.
|
|
214
|
+
const refusals = refusalLookup(opts.evidence, lang);
|
|
205
215
|
let compositePath: string | undefined;
|
|
206
216
|
if (!flat) {
|
|
207
|
-
const composite = compositeDoc(result, {
|
|
217
|
+
const composite = compositeDoc(result, {
|
|
218
|
+
standard,
|
|
219
|
+
lang,
|
|
220
|
+
crops: budgetedCrops,
|
|
221
|
+
...(refusals ? { refusals } : {}),
|
|
222
|
+
nav: nav.map((n) => ({ ...n, current: n.href === `./${compositeName}` })),
|
|
223
|
+
});
|
|
208
224
|
if (notices.length) composite.blocks.unshift({ kind: "note", tone: "warn", runs: noticeRuns(notices) });
|
|
209
225
|
compositePath = join(opts.outDir, compositeName);
|
|
210
226
|
writeFileSync(compositePath, renderHtmlDocument(composite));
|
|
@@ -253,6 +269,7 @@ export function writeHtml(result: AuditResult, opts: EmitOpts): EmitResult {
|
|
|
253
269
|
standard,
|
|
254
270
|
lang,
|
|
255
271
|
crops: fileCrops,
|
|
272
|
+
...(refusals ? { refusals } : {}),
|
|
256
273
|
nav: sheetNav,
|
|
257
274
|
// Where `cmdPages`' screenshot copier already put the capture: `assets/<id>.png`,
|
|
258
275
|
// beside the entry point, seen from wherever this sheet sits.
|
package/src/html-report.ts
CHANGED
|
@@ -32,14 +32,32 @@ import type { AuditResult, Finding, Lang, PageResult, Status } from "./types.js"
|
|
|
32
32
|
* `cropFor`, and for the same reason: this module never learns how a finding is keyed. */
|
|
33
33
|
export type CropLookup = (f: Finding) => { href: string; alt: string } | undefined;
|
|
34
34
|
|
|
35
|
+
/** What the evidence tier REFUSED to draw, for one page or — with `null` — for the whole
|
|
36
|
+
* run. Same shape as the crop lookup and for the same reason: this module never learns how
|
|
37
|
+
* the evidence tier works, only what it decided (src/evidence.ts `evidenceRefusals`). */
|
|
38
|
+
export type RefusalLookup = (pageId: string | null) => { headline: string; reasons: string[] } | undefined;
|
|
39
|
+
|
|
35
40
|
export interface HtmlReportOpts {
|
|
36
41
|
standard?: StandardId;
|
|
37
42
|
lang?: Lang;
|
|
38
43
|
crops?: CropLookup;
|
|
44
|
+
/** An occurrence with no picture must never read as an occurrence with no defect, so the
|
|
45
|
+
* document states what it did not draw. Without this, the HTML — the deliverable that
|
|
46
|
+
* actually reaches the client — is the one surface that keeps the omission to itself. */
|
|
47
|
+
refusals?: RefusalLookup;
|
|
39
48
|
/** Sibling documents, for the nav. */
|
|
40
49
|
nav?: Doc["nav"];
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/** The refusal list as blocks: the sentence, then one item per reason. */
|
|
53
|
+
function refusalBlocks(r: { headline: string; reasons: string[] } | undefined): Block[] {
|
|
54
|
+
if (!r) return [];
|
|
55
|
+
return [
|
|
56
|
+
{ kind: "note", tone: "warn", runs: [{ text: r.headline }] },
|
|
57
|
+
{ kind: "list", items: r.reasons.map((text) => [{ text }]) },
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
const T = {
|
|
44
62
|
fr: {
|
|
45
63
|
reportTitle: "Rapport de conformité",
|
|
@@ -91,7 +109,6 @@ const T = {
|
|
|
91
109
|
openPages: "Rapport page par page",
|
|
92
110
|
noScreenshot: "Aucune capture d'écran pour cette page.",
|
|
93
111
|
screenshotAlt: (n: string) => `Capture d'écran de la page ${n}`,
|
|
94
|
-
notImaged: (n: number) => `${n} occurrence(s) n'ont pas pu être illustrées.`,
|
|
95
112
|
},
|
|
96
113
|
en: {
|
|
97
114
|
reportTitle: "Conformance report",
|
|
@@ -143,7 +160,6 @@ const T = {
|
|
|
143
160
|
openPages: "Page-by-page report",
|
|
144
161
|
noScreenshot: "No screenshot for this page.",
|
|
145
162
|
screenshotAlt: (n: string) => `Screenshot of the ${n} page`,
|
|
146
|
-
notImaged: (n: number) => `${n} occurrence(s) could not be illustrated.`,
|
|
147
163
|
},
|
|
148
164
|
} as const;
|
|
149
165
|
|
|
@@ -262,10 +278,13 @@ function resolveOccurrence(f: Finding, lang: Lang): string {
|
|
|
262
278
|
}
|
|
263
279
|
|
|
264
280
|
/** §2 + §Recommendations — every non-conformity, then the advisory units. */
|
|
265
|
-
function findingsBlocks(result: AuditResult, standard: StandardId, lang: Lang, level: 2 | 3, crops?: CropLookup): Block[] {
|
|
281
|
+
function findingsBlocks(result: AuditResult, standard: StandardId, lang: Lang, level: 2 | 3, crops?: CropLookup, refusals?: RefusalLookup): Block[] {
|
|
266
282
|
const t = T[lang];
|
|
267
283
|
const { nc, advisory } = partitionUnits(prdUnits(result, standard, lang));
|
|
268
284
|
const out: Block[] = [{ kind: "heading", level: 2, text: t.ncTitle, id: "nc" }];
|
|
285
|
+
// Before the first figure, not after the last: a reader who stops scrolling must already
|
|
286
|
+
// know that the pictures below are a subset of the occurrences listed beside them.
|
|
287
|
+
out.push(...refusalBlocks(refusals?.(null)));
|
|
269
288
|
if (!nc.length) out.push({ kind: "para", runs: [{ text: t.noNc }] });
|
|
270
289
|
for (const u of nc) out.push(...criterionBlocks(u, standard, lang, level === 2 ? 3 : 4, crops));
|
|
271
290
|
if (advisory.length) {
|
|
@@ -391,7 +410,7 @@ export function compositeDoc(result: AuditResult, opts: HtmlReportOpts = {}): Do
|
|
|
391
410
|
const h = headline(result, standard, lang);
|
|
392
411
|
const blocks: Block[] = [...synthesisBlocks(result, standard, lang)];
|
|
393
412
|
if (h.agentRuled) blocks.push({ kind: "note", tone: "warn", runs: ticks(agentMarkNote(lang)) });
|
|
394
|
-
blocks.push(...findingsBlocks(result, standard, lang, 2, opts.crops));
|
|
413
|
+
blocks.push(...findingsBlocks(result, standard, lang, 2, opts.crops, opts.refusals));
|
|
395
414
|
blocks.push(...scoreboardBlocks(result, standard, lang));
|
|
396
415
|
const scope = pagesOf(result);
|
|
397
416
|
if (scope.length) blocks.push(...crossGridBlocks(result, derivePages(result, scope), standard, lang));
|
|
@@ -435,6 +454,10 @@ export function pageDoc(result: AuditResult, page: PageResult, opts: HtmlReportO
|
|
|
435
454
|
blocks.push(
|
|
436
455
|
opts.screenshot ? { kind: "figure", src: opts.screenshot, alt: t.screenshotAlt(page.name) } : { kind: "para", runs: [{ text: t.noScreenshot, em: true }] },
|
|
437
456
|
);
|
|
457
|
+
// Right after the capture and before the grid — the same place the Markdown sheet puts it
|
|
458
|
+
// (src/pages-report.ts), so a reader moving between the two documents finds it twice in
|
|
459
|
+
// the same spot rather than once in one of them.
|
|
460
|
+
blocks.push(...refusalBlocks(opts.refusals?.(page.id)));
|
|
438
461
|
blocks.push(...pageGridBlocks(result, page, standard, lang));
|
|
439
462
|
|
|
440
463
|
// The page's OWN findings, through the same view the Markdown sheet uses — so a criterion
|
package/src/pages-report.ts
CHANGED
|
@@ -155,10 +155,15 @@ export interface PageReportOpts {
|
|
|
155
155
|
/** The annotated crop illustrating one occurrence, when the evidence tier drew one. Passed
|
|
156
156
|
* straight through to the auditor block, which owns the one place a crop is emitted. */
|
|
157
157
|
cropFor?: AuditorCropLookup;
|
|
158
|
-
/** What the evidence tier could NOT draw on
|
|
158
|
+
/** What the evidence tier could NOT draw on a page, and why — already localized. Printed
|
|
159
159
|
* under the screenshot so a reader is never left to infer that an absent image means an
|
|
160
|
-
* absent defect.
|
|
161
|
-
|
|
160
|
+
* absent defect.
|
|
161
|
+
*
|
|
162
|
+
* A LOOKUP, not a list, because `renderPagesDocument` renders every page from one opts
|
|
163
|
+
* object: a list would print one page's refusals under all of them, and the caller that
|
|
164
|
+
* wanted to avoid that instead passed nothing at all — which is how the combined document
|
|
165
|
+
* came to carry the crops without the refusals. */
|
|
166
|
+
evidenceNotice?: (pageId: string) => string[];
|
|
162
167
|
}
|
|
163
168
|
|
|
164
169
|
/** One criterion's line on one page's sheet. Exported because the HTML renderer and the CI
|
|
@@ -270,7 +275,8 @@ export function renderPageReport(result: AuditResult, page: PageResult, opts: Pa
|
|
|
270
275
|
|
|
271
276
|
// What the evidence tier refused to draw, and why. An unillustrated occurrence must never
|
|
272
277
|
// read as an absent one — the whole posture of this engine is that nothing is cut silently.
|
|
273
|
-
|
|
278
|
+
const refused = opts.evidenceNotice?.(page.id) ?? [];
|
|
279
|
+
if (refused.length) out.push(...refused, "");
|
|
274
280
|
|
|
275
281
|
// The grid carries the criterion's own numbered TESTS, not just its title and status:
|
|
276
282
|
// those tests are what has to be checked, and listing them only inside the
|
package/src/report.ts
CHANGED
|
@@ -9,7 +9,7 @@ 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";
|
|
11
11
|
import { prdUnits, partitionUnits } from "./prd.js";
|
|
12
|
-
import { renderAuditorUnit } from "./auditor.js";
|
|
12
|
+
import { renderAuditorUnit, type AuditorCropLookup } from "./auditor.js";
|
|
13
13
|
import { resolveMessage } from "./messages.js";
|
|
14
14
|
import { attributePages, derivePages, pagesOf, renderPageGrid } from "./pages.js";
|
|
15
15
|
import { PAGES_DIR } from "./snapshot.js";
|
|
@@ -280,6 +280,9 @@ function render(
|
|
|
280
280
|
// Where this report will be WRITTEN. Only used to resolve the per-page screenshots
|
|
281
281
|
// relatively; absent ⇒ paths relative to the CWD, which is what stdout wants.
|
|
282
282
|
outDir?: string;
|
|
283
|
+
// The annotated crop for an occurrence, when the evidence tier drew one. Absent ⇒ every
|
|
284
|
+
// byte below is what it was before the tier existed (tests/__snapshots__/auditor).
|
|
285
|
+
cropFor?: AuditorCropLookup;
|
|
283
286
|
},
|
|
284
287
|
): string {
|
|
285
288
|
const s = L[lang];
|
|
@@ -332,7 +335,7 @@ function render(
|
|
|
332
335
|
const group = ncUnits.filter((u) => u.severity === sev);
|
|
333
336
|
if (!group.length) continue;
|
|
334
337
|
out.push(`### ${ICON[sev]} ${s.sev[sev]} (${group.length})`, "");
|
|
335
|
-
for (const u of group) out.push(...renderAuditorUnit(u, opts.standard, lang, { heading: "####" }));
|
|
338
|
+
for (const u of group) out.push(...renderAuditorUnit(u, opts.standard, lang, { heading: "####", ...(opts.cropFor ? { cropFor: opts.cropFor } : {}) }));
|
|
336
339
|
}
|
|
337
340
|
}
|
|
338
341
|
|
|
@@ -342,7 +345,8 @@ function render(
|
|
|
342
345
|
// the NC over-under-projection gate never see them). Only emitted when present.
|
|
343
346
|
if (advisoryUnits.length) {
|
|
344
347
|
out.push(`## 💡 ${s.recTitle}`, "", `> ${s.recNote}`, "");
|
|
345
|
-
for (const u of advisoryUnits)
|
|
348
|
+
for (const u of advisoryUnits)
|
|
349
|
+
out.push(...renderAuditorUnit(u, opts.standard, lang, { heading: "###", ...(opts.cropFor ? { cropFor: opts.cropFor } : {}) }));
|
|
346
350
|
}
|
|
347
351
|
|
|
348
352
|
// « Grille par page » — the per-page criterion matrix. RGAA is a per-page norm, so this is
|
|
@@ -485,9 +489,9 @@ export function reportGroups(r: AuditResult, lang: Lang = "en"): ReportGroup[] {
|
|
|
485
489
|
return r.guidelines.map((g) => ({ key: g.key, title: guidelineTitle(g.key, lang) ?? g.title, rows: byGuideline.get(g.key) ?? [] }));
|
|
486
490
|
}
|
|
487
491
|
|
|
488
|
-
export function renderReport(r: AuditResult, lang: Lang = "en", outDir?: string): string {
|
|
492
|
+
export function renderReport(r: AuditResult, lang: Lang = "en", outDir?: string, cropFor?: AuditorCropLookup): string {
|
|
489
493
|
const s = L[lang];
|
|
490
|
-
return render(r, lang, { std: s.wcagStd, groupHead: s.byGuideline, groups: reportGroups(r, lang), standard: CORE, outDir });
|
|
494
|
+
return render(r, lang, { std: s.wcagStd, groupHead: s.byGuideline, groups: reportGroups(r, lang), standard: CORE, outDir, ...(cropFor ? { cropFor } : {}) });
|
|
491
495
|
}
|
|
492
496
|
|
|
493
497
|
/** A PACK report's criterion rows, grouped by theme. The pack twin of `reportGroups`, and
|
|
@@ -524,7 +528,7 @@ export function packReportGroups(r: AuditResult, pack: StandardPack, lang: Lang
|
|
|
524
528
|
}
|
|
525
529
|
|
|
526
530
|
/** A derived report for a country standards pack (RGAA, …), projected from the WCAG audit. */
|
|
527
|
-
export function renderPackReport(r: AuditResult, pack: StandardPack, lang: Lang = "en", outDir?: string): string {
|
|
531
|
+
export function renderPackReport(r: AuditResult, pack: StandardPack, lang: Lang = "en", outDir?: string, cropFor?: AuditorCropLookup): string {
|
|
528
532
|
const derived = derivePackResults(r, pack.key);
|
|
529
533
|
const std = `${pack.name} ${pack.baseVersion}`;
|
|
530
534
|
// Owner decision: a pack (RGAA) report is flagged PARTIAL while any needs-rendering
|
|
@@ -543,6 +547,7 @@ export function renderPackReport(r: AuditResult, pack: StandardPack, lang: Lang
|
|
|
543
547
|
// CWD instead of the report's own directory, so a pack report written to `audits/`
|
|
544
548
|
// carried links that only worked when read from the repo root.
|
|
545
549
|
outDir,
|
|
550
|
+
...(cropFor ? { cropFor } : {}),
|
|
546
551
|
});
|
|
547
552
|
}
|
|
548
553
|
|
|
@@ -550,13 +555,17 @@ export interface ReportOpts {
|
|
|
550
555
|
out: string;
|
|
551
556
|
lang: Lang;
|
|
552
557
|
standard: StandardId;
|
|
558
|
+
/** The evidence tier's crops, when `--evidence` asked for them. The hrefs are relative to
|
|
559
|
+
* `out`, which is where this report is written — so the Markdown REFERENCES the same files
|
|
560
|
+
* the composite inlines, instead of the run writing images no document points at. */
|
|
561
|
+
cropFor?: AuditorCropLookup;
|
|
553
562
|
}
|
|
554
563
|
|
|
555
564
|
/** Render and write the report; returns the written path. The WCAG report is canonical
|
|
556
565
|
* (`wcag-<date>.md`); a pack report is a derived `<pack>-<date>.md`. */
|
|
557
566
|
export function writeReport(r: AuditResult, opts: ReportOpts): string {
|
|
558
567
|
const core = isCore(opts.standard);
|
|
559
|
-
const md = core ? renderReport(r, opts.lang, opts.out) : renderPackReport(r, loadPack(opts.standard), opts.lang, opts.out);
|
|
568
|
+
const md = core ? renderReport(r, opts.lang, opts.out, opts.cropFor) : renderPackReport(r, loadPack(opts.standard), opts.lang, opts.out, opts.cropFor);
|
|
560
569
|
mkdirSync(opts.out, { recursive: true });
|
|
561
570
|
const path = join(opts.out, `${core ? "wcag" : opts.standard}-${r.date}.md`);
|
|
562
571
|
writeFileSync(path, md);
|
|
@@ -34,21 +34,26 @@ function resolve(opts: GithubOptions): Resolved {
|
|
|
34
34
|
const mode = opts.transport ?? "auto";
|
|
35
35
|
const api = (env.GITHUB_API_URL || "https://api.github.com").replace(/\/+$/, "");
|
|
36
36
|
const token = env.GH_TOKEN || env.GITHUB_TOKEN;
|
|
37
|
-
|
|
37
|
+
// LAZY, because the probe is `gh auth status` — a subprocess that calls github.com. REST is
|
|
38
|
+
// the transport for the runners where `gh` is absent or useless, so a caller who already
|
|
39
|
+
// named REST must not pay for a network round trip whose answer is then discarded.
|
|
40
|
+
const probe = opts.cliAvailable ?? ghAvailable;
|
|
41
|
+
let cached: boolean | undefined;
|
|
42
|
+
const cliOk = (): boolean => (cached ??= probe());
|
|
38
43
|
|
|
39
44
|
if (mode === "cli") {
|
|
40
|
-
return cliOk ? { transport: "cli", api } : { transport: "cli", api, reason: "`gh` is not installed or not authenticated (run `gh auth login`)" };
|
|
45
|
+
return cliOk() ? { transport: "cli", api } : { transport: "cli", api, reason: "`gh` is not installed or not authenticated (run `gh auth login`)" };
|
|
41
46
|
}
|
|
42
47
|
// Only REST needs the repo slug, and only REST should pay for a `git` subprocess to find
|
|
43
48
|
// it — the CLI transport already knows which checkout it is standing in.
|
|
44
|
-
const repo = env.ULTRA11Y_GITHUB_REPO || env.GITHUB_REPOSITORY || (mode === "rest" || !cliOk ? gitRemoteSlug("github.com") : undefined);
|
|
49
|
+
const repo = env.ULTRA11Y_GITHUB_REPO || env.GITHUB_REPOSITORY || (mode === "rest" || !cliOk() ? gitRemoteSlug("github.com") : undefined);
|
|
45
50
|
if (mode === "rest") {
|
|
46
51
|
if (!token) return { transport: "rest", api, reason: "no GitHub token — set GH_TOKEN or GITHUB_TOKEN" };
|
|
47
52
|
if (!repo) return { transport: "rest", api, token, reason: "no repository — set GITHUB_REPOSITORY (owner/name) or ULTRA11Y_GITHUB_REPO" };
|
|
48
53
|
return { transport: "rest", api, token, repo };
|
|
49
54
|
}
|
|
50
55
|
// auto: the CLI first (it owns its auth), then REST, then say what BOTH were missing.
|
|
51
|
-
if (cliOk) return { transport: "cli", api };
|
|
56
|
+
if (cliOk()) return { transport: "cli", api };
|
|
52
57
|
if (token && repo) return { transport: "rest", api, token, repo };
|
|
53
58
|
const missing = [!token ? "a token (GH_TOKEN/GITHUB_TOKEN)" : "", !repo ? "a repository (GITHUB_REPOSITORY)" : ""].filter(Boolean);
|
|
54
59
|
return {
|
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 = "4.
|
|
3
|
+
export const VERSION = "4.2.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,
|