ultra11y 2.32.1 → 3.1.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/cli.ts CHANGED
@@ -69,6 +69,7 @@ Usage:
69
69
  ultra11y audit [--changed | --since <ref> | --staged] [--max-files <n>] [--dedup exact|normalized|off] [--baseline <file>] [--fail-on blocking|major|minor]
70
70
  ultra11y audit [--captures <dir>] [--no-captures] [--require-captures] (rendered-DOM captures + .ultra11y/pages snapshots: audit real HTML)
71
71
  ultra11y audit [--format sarif|github] (CI: SARIF for code scanning, or inline annotations + job summary)
72
+ ultra11y audit --in <audit.json> [--fail-on blocking|major|minor] [--format sarif|github] (re-gate an audit already computed — e.g. one carrying adjudicated verdicts — without a second detection pass)
72
73
  ultra11y report --in <audit.json> [--out <dir>] [--standard <pack>] [--format sarif|github] [--lang auto|en|fr]
73
74
  ultra11y prd --in <audit.json> [--out <dir>] [--split criterion] [--format audit|doc|remediation] [--no-technical] [--standard <pack>] [--lang auto|en|fr]
74
75
  ultra11y tickets --in <audit.json> [--provider auto|github|gitlab|jira] [--grain criterion|page|page-criterion|single|file] [--transport auto|cli|rest]
@@ -751,7 +752,90 @@ function emitCiFormat(result: AuditResult, format: CiFormat, standard: StandardI
751
752
  }
752
753
  }
753
754
 
755
+ // `audit --in <audit.json>` — RE-GATE an audit that was already computed, instead of
756
+ // computing one. It exists for one caller: a pipeline that folded ADJUDICATED verdicts into
757
+ // audit-latest.json and now wants the same severity gate applied to that result. Re-running
758
+ // detection there would be wrong twice over — it would not see the verdicts, and it would
759
+ // spend a second full pass to reach a conclusion the file already holds.
760
+ //
761
+ // Deliberately narrow: this path GATES and RENDERS, nothing else. Every flag that would
762
+ // change what is audited is refused rather than ignored, because a gate that silently drops
763
+ // your scoping is a gate nobody can trust. Re-running detection is what `audit <globs>` is
764
+ // for.
765
+ async function cmdAuditFromFile(p: ParsedArgs, inPath: string): Promise<number> {
766
+ const scoping = [
767
+ "since",
768
+ "changed",
769
+ "staged",
770
+ "baseline",
771
+ "graph",
772
+ "cross-file",
773
+ "jsx",
774
+ "include",
775
+ "exclude",
776
+ "ext",
777
+ "max-files",
778
+ "dedup",
779
+ "captures",
780
+ "no-captures",
781
+ "require-captures",
782
+ "no-default-excludes",
783
+ "out",
784
+ ];
785
+ const offenders = scoping.filter((f) => p.flags[f] !== undefined);
786
+ if (p.positionals.length) offenders.unshift("<paths>");
787
+ if (offenders.length) {
788
+ console.error(
789
+ `ultra11y audit: --in re-gates an audit that already exists, so these have nothing left to change: ${offenders.join(", ")}. Drop them, or run a fresh audit without --in.`,
790
+ );
791
+ return 2;
792
+ }
793
+
794
+ let result: AuditResult;
795
+ try {
796
+ const parsed = JSON.parse(inPath === "-" ? await readStdin() : readText(inPath)) as unknown;
797
+ if (!isCurrentAudit(parsed)) {
798
+ console.error("ultra11y audit: --in is not a current ultra11y AuditResult (WCAG-keyed, schema v2). Re-run `audit`.");
799
+ return 2;
800
+ }
801
+ result = parsed;
802
+ } catch {
803
+ console.error(`ultra11y audit: --in file not found or not valid JSON: ${inPath}.`);
804
+ return 2;
805
+ }
806
+
807
+ const lang = resolveLang(p.flags, { audit: result });
808
+
809
+ const failOnRaw = p.flags["fail-on"];
810
+ const failOnParsed = parseFailOn(failOnRaw);
811
+ if (failOnRaw !== undefined && failOnParsed === null) {
812
+ console.error(`ultra11y audit: --fail-on must be blocking|major|minor (got "${String(failOnRaw)}").`);
813
+ return 2;
814
+ }
815
+ const ciFormat = parseCiFormat(p.flags.format);
816
+ if (ciFormat === null) {
817
+ console.error(`ultra11y audit: --format must be sarif|github (got "${String(p.flags.format)}").`);
818
+ return 2;
819
+ }
820
+
821
+ const failOnSet = failOnRaw !== undefined;
822
+ const failOn = failOnSet ? (failOnParsed ?? "bloquant") : undefined;
823
+ const failing = failOn ? findingsAtOrAbove(result.findings, failOn) : [];
824
+
825
+ if (ciFormat) emitCiFormat(result, ciFormat, CORE, lang, failOn);
826
+ else if (p.flags.json) console.log(JSON.stringify(result, null, 2));
827
+ else {
828
+ console.log(auditSummary(result, lang));
829
+ if (failOnSet && failing.length)
830
+ console.error(lang === "fr" ? `✗ ${failing.length} non-conformité(s) ≥ ${failOn}.` : `✗ ${failing.length} non-conformity(ies) ≥ ${failOn}.`);
831
+ }
832
+ return failing.length ? 1 : 0;
833
+ }
834
+
754
835
  async function cmdAudit(p: ParsedArgs): Promise<number> {
836
+ const inFlag = p.flags.in;
837
+ if (typeof inFlag === "string" && inFlag) return cmdAuditFromFile(p, inFlag);
838
+
755
839
  const inputs = p.positionals.length ? p.positionals : ["."];
756
840
  if (inputs.length === 0) {
757
841
  console.error("ultra11y audit: provide files/globs, or '-' to read stdin.");
@@ -0,0 +1,499 @@
1
+ // EVIDENCE TIER — a non-conformity, shown.
2
+ //
3
+ // A rendered finding cites `.ultra11y/pages/accueil/dom.html:412` (`div.card`), and under the
4
+ // rules that anchor at the document element it cites `dom.html:2` — the same line, for
5
+ // hundreds of findings at once. `selectorHint` is doing all the locating work and it is lossy
6
+ // by construction (src/rules/rule.ts `selectorOf`: first class only, href truncated). For a
7
+ // reviewer who did not write the page, that is not a location. A picture is.
8
+ //
9
+ // Everything this needs is already on disk and already verified:
10
+ // • `screen.png` — the page as the browser painted it
11
+ // • `boxes.json` — every element's rect, joined to the DOM by document-order ordinal
12
+ // • `Finding.sourceStart` — the byte offset of the anchoring element in that same dom.html
13
+ // So the join is `sourceStart -> ordinal -> box -> pixels`, with no new capture, no new
14
+ // dependency, and no new field on Finding.
15
+ //
16
+ // WHY DERIVE, NOT STAMP. A box could have been written onto Finding when the rule fired.
17
+ // It is not, deliberately: a pixel rectangle is a property of an IMAGE, not of a finding. A
18
+ // stamped box survives into the audit JSON and goes silently wrong the moment that JSON is
19
+ // re-rendered against a re-captured page — and silently wrong is the one failure mode this
20
+ // engine refuses. Deriving re-runs the same verified join every time, and refuses wholesale
21
+ // when it does not hold, exactly as `align()` already does for styles and boxes.
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
27
+ // not in the image, because every producer captures the VIEWPORT on purpose so the screenshot
28
+ // shares boxes.json's coordinate system (src/integrations/playwright.ts). None of these is a
29
+ // bug to work around; each is a fact to state.
30
+ import { createHash } from "node:crypto";
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { findingId } from "./baseline.js";
34
+ import { parseHtml } from "./parse/html.js";
35
+ import { cropImage, decodePng, encodePng, fillRect, strokeRect } from "./pixel.js";
36
+ import { PAGES_DIR, alignedBoxes, isSnapshotDom, snapshotDir, validateSnapshotMeta } from "./snapshot.js";
37
+ import type { Image, Raster, Rect } from "./pixel.js";
38
+ import type { SnapshotMeta, SnapshotViewport } from "./snapshot.js";
39
+ import type { AuditResult, BoxDigest, BoxEntry, Finding, Lang } from "./types.js";
40
+
41
+ /** 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
56
+
57
+ export interface EvidenceGeometry {
58
+ /** Context kept around the element, in CSS pixels. */
59
+ pad: number;
60
+ minWidth: number;
61
+ minHeight: number;
62
+ maxWidth: number;
63
+ maxHeight: number;
64
+ }
65
+
66
+ // The MINIMUM is the load-bearing number. A `rendered-link-colour-only` box is a 40×18 inline
67
+ // link; cropped tight it is an unreadable smear, and the whole question that rule asks —
68
+ // "is this distinguishable from the running text around it?" — is a question about the
69
+ // surroundings. So the crop is padded out to something a human can judge.
70
+ export const DEFAULT_GEOMETRY: EvidenceGeometry = { pad: 24, minWidth: 320, minHeight: 120, maxWidth: 960, maxHeight: 540 };
71
+
72
+ /** De-duplication. Issue #16's real audit holds 472 findings of ONE rule across 38 pages for
73
+ * 7 distinct selectors: one image per occurrence would be 472 pictures of the same defect.
74
+ * One per (page, rule, selector), then hard caps — and the report says what it did not draw. */
75
+ export const DEFAULT_CAPS = { perRule: 6, perPage: 12, total: 200 };
76
+
77
+ export interface PageEvidenceContext {
78
+ pageId: string;
79
+ img: Image;
80
+ /** Image pixels per CSS pixel. Derived, never assumed — see `resolveScale`. */
81
+ scale: number;
82
+ viewport: SnapshotViewport;
83
+ /** `Finding.sourceStart` → document-order ordinal, the key `boxes.json` is joined on. */
84
+ ordinalOf: Map<number, number>;
85
+ boxes: Map<number, BoxEntry>;
86
+ truncated: boolean;
87
+ }
88
+
89
+ export interface EvidenceCrop {
90
+ findingId: string;
91
+ page: string;
92
+ ruleId: string;
93
+ criteriaId: string;
94
+ /** Path relative to the output directory, for a Markdown or HTML `src`. */
95
+ href: string;
96
+ /** Absolute path on disk. */
97
+ path: string;
98
+ width: number;
99
+ height: number;
100
+ box: BoxEntry;
101
+ scale: number;
102
+ /** The element ran past the capture edge: what is drawn is part of it, not all of it. */
103
+ clipped: boolean;
104
+ alt: Record<Lang, string>;
105
+ }
106
+
107
+ export interface EvidencePageTally {
108
+ located: number;
109
+ imaged: number;
110
+ skipped: Partial<Record<EvidenceSkip, number>>;
111
+ }
112
+
113
+ export interface EvidenceManifest {
114
+ crops: Map<string, EvidenceCrop>;
115
+ skipped: Map<string, EvidenceSkip>;
116
+ perPage: Map<string, EvidencePageTally>;
117
+ totals: EvidencePageTally;
118
+ }
119
+
120
+ export interface EvidenceOptions {
121
+ /** Where `assets/<page-id>/` is written. */
122
+ outDir: string;
123
+ /** Repository root holding `.ultra11y/pages`. Defaults to the process cwd. */
124
+ root?: string;
125
+ caps?: Partial<typeof DEFAULT_CAPS>;
126
+ geometry?: Partial<EvidenceGeometry>;
127
+ }
128
+
129
+ // The ring. Red alone would encode the meaning by colour, which is a 1.4.1 failure — and this
130
+ // tool exists to find those. So the mark carries three independent channels: a light halo (so
131
+ // it survives a dark page), a solid ring, and corner brackets whose SHAPE says "here" with no
132
+ // hue at all. A dashed inner line adds texture for a monochrome print.
133
+ const HALO = { r: 255, g: 255, b: 255, a: 0.92 };
134
+ const MARK = { r: 179, g: 38, b: 30, a: 1 }; // #b3261e — the NC colour the reports already use
135
+ const RING = 3;
136
+
137
+ /** The page id of a snapshot dom.html path, or null when it is not one. */
138
+ export function pageIdOfSnapshot(file: string): string | null {
139
+ if (!isSnapshotDom(file)) return null;
140
+ const posix = file.split("\\").join("/");
141
+ const at = posix.lastIndexOf(`${PAGES_DIR}/`);
142
+ if (at < 0) return null;
143
+ const rest = posix.slice(at + PAGES_DIR.length + 1);
144
+ const id = rest.slice(0, rest.indexOf("/"));
145
+ return id || null;
146
+ }
147
+
148
+ function readJson<T>(file: string): T | undefined {
149
+ try {
150
+ return JSON.parse(readFileSync(file, "utf8")) as T;
151
+ } catch {
152
+ return undefined;
153
+ }
154
+ }
155
+
156
+ /** Image pixels per CSS pixel.
157
+ *
158
+ * Nothing records `devicePixelRatio`, so it is derived from the image against the viewport
159
+ * the collector recorded. When that is unusable, the `<html>` box is the fallback — it is by
160
+ * definition the full viewport width. When BOTH are unusable the answer is genuinely unknown,
161
+ * and the honest result is no crop: a guessed scale of 1 on a 2× capture rings the wrong half
162
+ * of the page, which is worse than no picture. */
163
+ function resolveScale(img: Image, viewport: SnapshotViewport | undefined, boxes: Map<number, BoxEntry>): number | null {
164
+ const candidates = [viewport?.width, boxes.get(0)?.w];
165
+ for (const w of candidates) {
166
+ if (!w || w <= 0) continue;
167
+ const scale = img.width / w;
168
+ // A capture is 1×, 2× or 3×. Anything outside that band means the image and the boxes do
169
+ // not describe the same render, and pretending otherwise would misplace every ring.
170
+ if (scale >= 0.5 && scale <= 4) return scale;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /** Open a page snapshot's evidence, or say why it has none. Reads meta.json, dom.html,
176
+ * boxes.json and screen.png ONLY — styles.json and css.json can be megabytes and answer
177
+ * nothing here. */
178
+ export function loadPageEvidence(root: string, pageId: string): PageEvidenceContext | { skip: EvidenceSkip } {
179
+ const dir = snapshotDir(root, pageId);
180
+ const domFile = join(dir, "dom.html");
181
+ const shotFile = join(dir, "screen.png");
182
+ if (!existsSync(domFile)) return { skip: "no-snapshot" };
183
+ if (!existsSync(shotFile)) return { skip: "no-screenshot" };
184
+
185
+ let dom: string;
186
+ let shot: Buffer;
187
+ try {
188
+ dom = readFileSync(domFile, "utf8");
189
+ shot = readFileSync(shotFile);
190
+ } catch {
191
+ return { skip: "no-snapshot" };
192
+ }
193
+
194
+ const img = decodePng(shot);
195
+ if (!img) return { skip: "unreadable-image" };
196
+
197
+ const digest = readJson<BoxDigest>(join(dir, "boxes.json"));
198
+ if (!digest) return { skip: "no-boxes" };
199
+ const doc = parseHtml(dom, domFile);
200
+ const boxes = alignedBoxes(dom, digest, doc);
201
+ if (!boxes) return { skip: "no-boxes" };
202
+
203
+ const scale = resolveScale(img, metaOf(dir)?.viewport, boxes);
204
+ if (scale === null) return { skip: "unknown-scale" };
205
+
206
+ const ordinalOf = new Map<number, number>();
207
+ for (let i = 0; i < doc.elements.length; i++) {
208
+ const start = doc.elements[i]?.start;
209
+ if (start !== undefined && !ordinalOf.has(start)) ordinalOf.set(start, i);
210
+ }
211
+
212
+ const viewport: SnapshotViewport = metaOf(dir)?.viewport ?? { width: Math.round(img.width / scale), height: Math.round(img.height / scale) };
213
+ return { pageId, img, scale, viewport, ordinalOf, boxes, truncated: Boolean(digest.truncated) };
214
+ }
215
+
216
+ function metaOf(dir: string): SnapshotMeta | undefined {
217
+ const raw = readJson<unknown>(join(dir, "meta.json"));
218
+ if (raw === undefined) return undefined;
219
+ const v = validateSnapshotMeta(raw);
220
+ return v.ok && v.meta ? v.meta : undefined;
221
+ }
222
+
223
+ /** The crop rect around an element box, in IMAGE pixels: padded for context, grown to the
224
+ * legible minimum, capped at the maximum, and slid back inside the image rather than
225
+ * hanging off it. */
226
+ function cropRect(box: BoxEntry, g: EvidenceGeometry, scale: number, img: Image): Rect {
227
+ const bx = box.x * scale;
228
+ const by = box.y * scale;
229
+ const bw = box.w * scale;
230
+ const bh = box.h * scale;
231
+ const w = Math.min(Math.max(bw + g.pad * 2 * scale, g.minWidth * scale), g.maxWidth * scale, img.width);
232
+ const h = Math.min(Math.max(bh + g.pad * 2 * scale, g.minHeight * scale), g.maxHeight * scale, img.height);
233
+ let x = bx + bw / 2 - w / 2;
234
+ let y = by + bh / 2 - h / 2;
235
+ x = Math.max(0, Math.min(x, img.width - w));
236
+ y = Math.max(0, Math.min(y, img.height - h));
237
+ return { x, y, w, h };
238
+ }
239
+
240
+ /** Ring the element inside its crop. Three channels, none of them colour alone. */
241
+ function annotate(raster: Raster, target: Rect): void {
242
+ strokeRect(raster, { x: target.x - RING, y: target.y - RING, w: target.w + RING * 2, h: target.h + RING * 2 }, HALO, RING);
243
+ strokeRect(raster, target, MARK, RING);
244
+ strokeRect(raster, { x: target.x + RING, y: target.y + RING, w: target.w - RING * 2, h: target.h - RING * 2 }, HALO, 1, 6);
245
+
246
+ // Corner brackets: a shape cue that survives greyscale, a colour-blind reader and a print.
247
+ const arm = Math.max(6, Math.min(18, Math.round(Math.min(target.w, target.h) / 3)));
248
+ const t = RING;
249
+ const x0 = target.x - RING;
250
+ const y0 = target.y - RING;
251
+ const x1 = target.x + target.w + RING;
252
+ const y1 = target.y + target.h + RING;
253
+ for (const [cx, cy, dx, dy] of [
254
+ [x0, y0, 1, 1],
255
+ [x1, y0, -1, 1],
256
+ [x0, y1, 1, -1],
257
+ [x1, y1, -1, -1],
258
+ ] as const) {
259
+ fillRect(raster, { x: dx > 0 ? cx : cx - arm, y: dy > 0 ? cy - t : cy, w: arm, h: t }, MARK);
260
+ fillRect(raster, { x: dx > 0 ? cx - t : cx, y: dy > 0 ? cy : cy - arm, w: t, h: arm }, MARK);
261
+ }
262
+ }
263
+
264
+ export interface CroppedFinding {
265
+ png: Buffer;
266
+ width: number;
267
+ height: number;
268
+ box: BoxEntry;
269
+ scale: number;
270
+ clipped: boolean;
271
+ }
272
+
273
+ /** One finding → one annotated PNG, or the reason there is none. */
274
+ export function cropFinding(ctx: PageEvidenceContext, f: Finding, geometry?: Partial<EvidenceGeometry>): CroppedFinding | { skip: EvidenceSkip } {
275
+ const g = { ...DEFAULT_GEOMETRY, ...geometry };
276
+ if (f.sourceStart === undefined) return { skip: "no-offsets" };
277
+ const ordinal = ctx.ordinalOf.get(f.sourceStart);
278
+ if (ordinal === undefined) return { skip: "unjoinable" };
279
+ // The document element: its box IS the page, so ringing it would outline the screenshot.
280
+ // The page's own screenshot already illustrates that, and the sheet already shows it.
281
+ if (ordinal === 0) return { skip: "page-scope" };
282
+ const box = ctx.boxes.get(ordinal);
283
+ if (!box) return { skip: ctx.truncated ? "truncated" : "no-boxes" };
284
+ if (box.w <= 0 || box.h <= 0) return { skip: "zero-area" };
285
+ const vpArea = ctx.viewport.width * ctx.viewport.height;
286
+ if (vpArea > 0 && (box.w * box.h) / vpArea >= 0.7) return { skip: "page-scope" };
287
+
288
+ // Viewport-only captures, on every producer, on purpose: the screenshot shares boxes.json's
289
+ // coordinate system. An element mostly below the fold is not in the image to be drawn.
290
+ const visibleTop = Math.max(0, box.y * ctx.scale);
291
+ const visibleBottom = Math.min(ctx.img.height, (box.y + box.h) * ctx.scale);
292
+ const visibleFraction = box.h > 0 ? (visibleBottom - visibleTop) / (box.h * ctx.scale) : 0;
293
+ if (visibleFraction < 0.5) return { skip: "below-the-fold" };
294
+
295
+ const rect = cropRect(box, g, ctx.scale, ctx.img);
296
+ const crop = cropImage(ctx.img, rect);
297
+ if (!crop) return { skip: "below-the-fold" };
298
+
299
+ annotate(crop.raster, {
300
+ x: box.x * ctx.scale - crop.rect.x,
301
+ y: box.y * ctx.scale - crop.rect.y,
302
+ w: box.w * ctx.scale,
303
+ h: box.h * ctx.scale,
304
+ });
305
+
306
+ return {
307
+ png: encodePng(crop.raster),
308
+ width: crop.raster.width,
309
+ height: crop.raster.height,
310
+ box,
311
+ scale: ctx.scale,
312
+ clipped: visibleFraction < 1,
313
+ };
314
+ }
315
+
316
+ // ---- prose -------------------------------------------------------------------------------
317
+ // Every refusal has a sentence, in both languages. A missing image with no explanation reads
318
+ // as "the tool found nothing here", which is the opposite of what happened.
319
+
320
+ const S: Record<Lang, { alt: (sel: string, page: string) => string; notImaged: (n: number) => string; reasons: Record<EvidenceSkip, string> }> = {
321
+ fr: {
322
+ // No `N.N —` sequence: src/check.ts scans the WHOLE document for criterion mentions.
323
+ alt: (sel, page) => `Capture recadrée de l'élément ${sel} sur la page ${page}, entouré d'un cadre`,
324
+ notImaged: (n) => `${n} occurrence(s) ne sont pas illustrées :`,
325
+ reasons: {
326
+ "no-snapshot": "constat levé sur le code source, pas sur une page capturée — il n'y a pas de pixels à montrer",
327
+ "no-screenshot": "le producteur n'a pas fourni de capture pour cette page, donc le niveau pixel est inactif ici",
328
+ "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
+ "no-boxes":
330
+ "les positions d'éléments ne se vérifient pas contre le DOM sérialisé, elles ont donc été refusées en bloc plutôt que d'encadrer le mauvais élément",
331
+ truncated: "la page dépasse la limite de collecte : aucune position n'a été enregistrée pour cet élément",
332
+ "no-offsets": "le constat ne porte pas d'ancrage dans la source, il ne peut pas être joint à une position",
333
+ unjoinable: "l'ancrage du constat ne correspond à aucun élément du DOM sérialisé",
334
+ "page-scope": "le constat porte sur la page entière — la capture de page ci-dessus est l'illustration",
335
+ "zero-area": "l'élément n'occupe aucune surface peinte",
336
+ "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
+ "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",
339
+ },
340
+ },
341
+ en: {
342
+ alt: (sel, page) => `Cropped screenshot of the ${sel} element on the ${page} page, outlined`,
343
+ notImaged: (n) => `${n} occurrence(s) are not illustrated:`,
344
+ reasons: {
345
+ "no-snapshot": "raised on source code, not on a captured page — there are no pixels to show",
346
+ "no-screenshot": "the producer supplied no screenshot for this page, so the pixel tier is inactive here",
347
+ "unreadable-image": "the screenshot does not decode (the engine reads 8-bit truecolour only) — no image beats a wrong one",
348
+ "no-boxes": "the element positions do not verify against the serialized DOM, so they were refused wholesale rather than outlining the wrong element",
349
+ truncated: "the page exceeded the collection cap: no position was recorded for this element",
350
+ "no-offsets": "the finding carries no source anchor, so it cannot be joined to a position",
351
+ unjoinable: "the finding's anchor matches no element in the serialized DOM",
352
+ "page-scope": "the finding is about the whole page — the page screenshot above is the illustration",
353
+ "zero-area": "the element paints no box",
354
+ "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
+ "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",
357
+ },
358
+ },
359
+ };
360
+
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[] {
364
+ const t = pageId === null ? m.totals : m.perPage.get(pageId);
365
+ if (!t) return [];
366
+ const missing = t.located - t.imaged;
367
+ if (missing <= 0) return [];
368
+ 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;
375
+ }
376
+
377
+ // ---- the run -----------------------------------------------------------------------------
378
+
379
+ function bump(t: EvidencePageTally, reason: EvidenceSkip): void {
380
+ t.skipped[reason] = (t.skipped[reason] ?? 0) + 1;
381
+ }
382
+
383
+ /** A filesystem-safe, stable, collision-free name for a finding's crop. Derived from the same
384
+ * identity SARIF fingerprints use (src/baseline.ts `findingId`), hashed only because that
385
+ * identity contains path separators and pipes. The identity itself is never altered. */
386
+ function cropName(f: Finding): string {
387
+ return `${createHash("sha1").update(findingId(f)).digest("hex").slice(0, 12)}.png`;
388
+ }
389
+
390
+ /** Draw every non-conformity that can be drawn, write the PNGs under
391
+ * `<outDir>/assets/<page-id>/`, and record what was not drawn and why.
392
+ *
393
+ * Pages are the OUTER loop and the context is dropped between them on purpose: a 2560×1440
394
+ * screenshot inflates to ~15 MB, and holding 38 of them at once does not fit. Do not hoist. */
395
+ export function writeEvidence(result: AuditResult, opts: EvidenceOptions): EvidenceManifest {
396
+ const root = opts.root ?? process.cwd();
397
+ const caps = { ...DEFAULT_CAPS, ...opts.caps };
398
+ const manifest: EvidenceManifest = {
399
+ crops: new Map(),
400
+ skipped: new Map(),
401
+ perPage: new Map(),
402
+ totals: { located: 0, imaged: 0, skipped: {} },
403
+ };
404
+
405
+ // The discriminator is the snapshot PATH, never `f.page`: attribution stamps a page id on
406
+ // source findings too (src/pages.ts), and those have no pixels.
407
+ const byPage = new Map<string, Finding[]>();
408
+ for (const f of [...result.findings, ...(result.packFindings ?? [])]) {
409
+ if (f.advisory) continue;
410
+ const page = pageIdOfSnapshot(f.file);
411
+ if (!page) continue;
412
+ const list = byPage.get(page);
413
+ if (list) list.push(f);
414
+ else byPage.set(page, [f]);
415
+ }
416
+
417
+ for (const [pageId, findings] of [...byPage].sort((a, b) => a[0].localeCompare(b[0]))) {
418
+ const tally: EvidencePageTally = { located: findings.length, imaged: 0, skipped: {} };
419
+ manifest.perPage.set(pageId, tally);
420
+ manifest.totals.located += findings.length;
421
+
422
+ const ctx = loadPageEvidence(root, pageId);
423
+ if ("skip" in ctx) {
424
+ for (const f of findings) {
425
+ manifest.skipped.set(findingId(f), ctx.skip);
426
+ bump(tally, ctx.skip);
427
+ bump(manifest.totals, ctx.skip);
428
+ }
429
+ continue;
430
+ }
431
+
432
+ const dir = join(opts.outDir, "assets", pageId);
433
+ let wrote = false;
434
+ const perRule = new Map<string, number>();
435
+ const seen = new Set<string>();
436
+ let onPage = 0;
437
+
438
+ for (const f of findings) {
439
+ const id = findingId(f);
440
+ // One image per distinct element per page: 472 findings of one rule collapse to the
441
+ // handful of DIFFERENT things actually wrong.
442
+ const key = `${f.ruleId}\u0000${f.selectorHint}`;
443
+ 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");
448
+ continue;
449
+ }
450
+
451
+ const crop = cropFinding(ctx, f, opts.geometry);
452
+ if ("skip" in crop) {
453
+ manifest.skipped.set(id, crop.skip);
454
+ bump(tally, crop.skip);
455
+ bump(manifest.totals, crop.skip);
456
+ continue;
457
+ }
458
+
459
+ const name = cropName(f);
460
+ const path = join(dir, name);
461
+ try {
462
+ if (!wrote) {
463
+ mkdirSync(dir, { recursive: true });
464
+ wrote = true;
465
+ }
466
+ writeFileSync(path, crop.png);
467
+ } catch {
468
+ // A failed image write must never fail a report — the same posture the screenshot
469
+ // copier already takes. The finding keeps its file:line and is counted as not imaged.
470
+ manifest.skipped.set(id, "no-screenshot");
471
+ bump(tally, "no-screenshot");
472
+ bump(manifest.totals, "no-screenshot");
473
+ continue;
474
+ }
475
+
476
+ seen.add(key);
477
+ perRule.set(f.ruleId, ruleCount + 1);
478
+ onPage++;
479
+ tally.imaged++;
480
+ manifest.totals.imaged++;
481
+ manifest.crops.set(id, {
482
+ findingId: id,
483
+ page: pageId,
484
+ ruleId: f.ruleId,
485
+ criteriaId: f.criteriaId,
486
+ href: `./assets/${pageId}/${name}`,
487
+ path,
488
+ width: crop.width,
489
+ height: crop.height,
490
+ box: crop.box,
491
+ scale: crop.scale,
492
+ clipped: crop.clipped,
493
+ alt: { fr: S.fr.alt(f.selectorHint, pageId), en: S.en.alt(f.selectorHint, pageId) },
494
+ });
495
+ }
496
+ }
497
+
498
+ return manifest;
499
+ }