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/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. Most findings get no crop, and each reason is recorded and reported rather
24
- // than left as a gap: a source finding has no pixels, a component capture was rendered by
25
- // jsdom which paints nothing, a page-scope rule points at `<html>` and would "highlight" the
26
- // whole screenshot, and — the one that surprises people — an element below the fold is simply
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
- export type EvidenceSkip =
44
- | "no-snapshot" // not raised on a page snapshot's dom.html (source finding, component capture)
45
- | "no-screenshot" // the producer captured no image for this page
46
- | "unreadable-image" // screen.png is not 8-bit truecolour, or is corrupt
47
- | "no-boxes" // no boxes.json, or it did not verify against this dom.html
48
- | "truncated" // the collector hit its element cap; this element has no box
49
- | "no-offsets" // the finding carries no sourceStart (cannot be joined)
50
- | "unjoinable" // sourceStart matches no element in dom.html
51
- | "page-scope" // anchored at the document element: the page screenshot IS the illustration
52
- | "zero-area" // the element has no painted box (display:contents, empty inline)
53
- | "below-the-fold" // outside a viewport-only capture
54
- | "unknown-scale" // image-pixels-per-CSS-pixel is indeterminate
55
- | "capped"; // de-duplicated away by the caps below
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": "constat levé sur le code source, pas sur une page capturée — il n'y a pas de pixels à montrer",
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
- capped: "occurrences regroupées : une illustration par élément distinct, dans la limite fixée",
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": "raised on source code, not on a captured page — there are no pixels to show",
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
- capped: "occurrences grouped: one illustration per distinct element, up to the cap",
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
- /** What the report says about what it did NOT draw — per page, or overall when `pageId` is
362
- * null. Empty when everything located was imaged. */
363
- export function evidenceNotice(m: EvidenceManifest, pageId: string | null, lang: Lang): string[] {
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
- const out = [s.notImaged(missing)];
370
- for (const [reason, n] of Object.entries(t.skipped).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))) {
371
- if (!n) continue;
372
- out.push(`- ${n} — ${s.reasons[reason as EvidenceSkip]}`);
373
- }
374
- return out;
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
- if (seen.has(key) || ruleCount >= caps.perRule || onPage >= caps.perPage || manifest.crops.size >= caps.total) {
445
- manifest.skipped.set(id, "capped");
446
- bump(tally, "capped");
447
- bump(manifest.totals, "capped");
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 { EvidenceManifest } from "./evidence.js";
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, { standard, lang, crops: budgetedCrops, nav: nav.map((n) => ({ ...n, current: n.href === `./${compositeName}` })) });
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.
@@ -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
@@ -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 this page, and why — already localized. Printed
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
- evidenceNotice?: string[];
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
- if (opts.evidenceNotice?.length) out.push(...opts.evidenceNotice, "");
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) out.push(...renderAuditorUnit(u, opts.standard, lang, { heading: "###" }));
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
- const cliOk = (opts.cliAvailable ?? ghAvailable)();
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.1.0";
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,