๐Ÿ“ฆ EqualifyEverything / equalify-iris

๐Ÿ“„ lint.ts ยท 724 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724import { JSDOM, VirtualConsole } from "jsdom";
import axe from "axe-core";

// One of the elements a rule failed on: where it is, and what it looks like.
//
// axe reports every violation per node โ€” `target` is a CSS selector for the element and
// `html` is its markup โ€” and this result kept neither, only how many there were (#161). What
// the review loop was told was `aria-deprecated-role (minor): โ€ฆ [1 nodes]`, and what the
// document contained was 26 elements with a `role` on them, one of which was deprecated. The
// Reader had to find the failing element by reading a 140,000-character document for something
// that might break a rule it was given no example of, and the Copy Editor then had to find it
// again from whatever the Reader wrote. For several rules that is not a search a model can win:
// `aria-prohibited-attr` fires on an attribute that is legal on most roles, `duplicate-id` on
// the second of two ids that are individually unremarkable, `aria-deprecated-role` on a role
// one character from a valid neighbour (`doc-endnote` beside `doc-endnotes`). The selector
// turns each of those from a search into an edit.
export interface LintNode {
  // Against the document that was linted โ€” `wrapDocument(body)` โ€” and generated by axe as the
  // shortest selector that is unique in it. Measured shapes: `#skipped` where the element has
  // an id, `img[src$="a0.png"]` where an attribute distinguishes it, and
  // `section:nth-child(2) > p > img[src="a.png"]` where nothing does. The positional ones are
  // only true of the body they were computed from, which is the body the same round hands the
  // editor; the next round re-lints whatever came back, so nothing here is carried across a
  // rewrite.
  target: string;
  // The element's markup, folded to one line and cut to NODE_HTML_CHARS. Not the whole
  // element: axe gives the start tag alone for anything long, and truncates each attribute
  // value to about 20 characters, so this is an identifier rather than a copy of the content.
  html: string;
}

export interface LintViolation {
  id: string;
  impact: string | null;
  description: string;
  // How many elements the rule failed on โ€” the WHOLE count, not the number of examples below.
  // `/v1/quality` records it per rule (orchestrator.ts writes it as `count` in `run_signals`,
  // Store.qualityStats sums it), so it means what it has always meant.
  nodes: number;
  // A bounded sample of those elements. Optional because a violation can be constructed
  // without them โ€” a hand-built one in a test, a result from an older store โ€” and "no examples
  // recorded" is a different statement from "an empty list of them".
  examples?: LintNode[];
}

export interface LintResult {
  // Whether the document PASSED the gate โ€” which a document the gate never ran on did
  // not. This used to be `true` on a lint that threw (an environment failure was a
  // "degradation", and degrading meant reporting a pass), so #164's document shipped
  // recorded as clean: `lint_ok: true, violations: 0` beside a `lint_error` is the same
  // pair a flawless document produces, and every reader of it โ€” the review loop, the
  // quality tally, a human on the log line โ€” was given the good news.
  //
  // The degradation itself is unchanged and still deliberate: a linter that cannot run
  // must not cost a user the document it could not check. What changed is that saying so
  // is no longer saying the opposite. Which kind of failure it was stays in `errorWhere`;
  // nothing in the pipeline fails a run on this field.
  ok: boolean;
  // ABSENT when the lint did not run, rather than empty. The number of violations in a
  // check that did not happen is unknown, not none, and an empty array is a claim: every
  // caller that maps over it โ€” the Reader's lint summary, the per-rule quality signals โ€”
  // produced "no violations found" from it. Optional so the type makes each of them say
  // what it does with a lint that has no answer to give.
  violations?: LintViolation[];
  error?: string;
  // What failed, when something did. `error` is the sentence a human and the Reader
  // Agent both read; these are for whoever has to fix it.
  //
  // A gate that returns no verdict is only honest if its failure can be
  // chased down, and the first report of this happening on a real document (#144)
  // could not be: the message was "Octal escape sequences are not allowed in strict
  // mode", a JavaScript SyntaxError, which is not the case the code documented as
  // reachable (a stack overflow from a page too deeply nested) and which nobody could
  // reproduce from the message alone. `where` is what the message could not say โ€”
  // whether the failure came out of parsing the document, out of evaluating axe's own
  // source, or out of the run โ€” and it is a fact about which call threw rather than a
  // reading of the text, so splitting the steps below is what makes it exact.
  errorWhere?: "parse" | "inject" | "run";
  // The class of error and where it was raised. `stack` is trimmed to its first frames:
  // the top of it says which library the throw came from, which is the question, and the
  // whole thing would put a page of jsdom internals in every session's run log.
  errorName?: string;
  errorStack?: string;
  // Attributes in the linted document whose NAMES no valid markup produces (see
  // `malformedAttributes`). ABSENT rather than zero on the ordinary document, so a line carrying it
  // means something.
  //
  // Reported, not merely dropped, because the name is evidence about a bug one stage earlier and
  // there is no other symptom of it. An attribute called `1\"` is not something a page agent
  // writes; it is what the HTML parser makes of `aria-label=\"Page iii\"` arriving with its JSON
  // escaping still on it, and the same leak puts `\"doc-pagebreak\"` in a `role` and
  // `\"page-iii\"` in an `id` โ€” an invalid role, a marker that announces the wrong text, and a
  // dead target for every cross-page reference to it (#233, #234, #257). Two of those three are
  // findable only by reading the document; this one is now a number on a log line.
  malformedAttributes?: number;
  // How many of those had to be taken out of the lint's own copy of the document for axe to run at
  // all, which is a much smaller number and usually zero (absent when it is). Kept separate because
  // the two say different things: the count above is a measurement of a leak upstream, and this is
  // the one fact that makes the linted document differ from the delivered one. A run with this set
  // is a run that would otherwise have had NO verdict on any page.
  malformedAttributesRemoved?: number;
  // A bounded sample of the names, on the same argument as `LintNode.examples`: a count says how
  // much debris there is and a name says which leak made it, and the escaping shape is the whole
  // diagnosis. Bounded and cut for the same reason the node excerpts are โ€” these are characters
  // out of a user's document, and this field is logged. Any that were removed come first.
  malformedAttributeNames?: string[];
}

// Enough frames to name the throwing library and its caller, and few enough that a
// degraded lint stays one log line rather than a page of it.
const STACK_FRAMES = 6;

// Frames arrive as absolute paths, and this stack is logged on the `assembly` event, which
// `GET /v1/sessions/{id}/logs` serves to the session's owner โ€” so the deployment's directory
// layout would be disclosed to every uploader whose document degraded the gate. What the
// frames are FOR is naming the library the throw came from and its caller, and that is
// exactly what survives the trim: the install root goes, `node_modules/jsdom/lib/โ€ฆ` stays.
//
// Exported for the test that pins the frame shapes, because the shapes are the whole
// argument and only one of them occurs on the failure this environment can provoke: a
// stack from the deep-nesting `RangeError` is six jsdom frames, so the app's own frames,
// an ESM `file://` URL and a frame from outside both trees are unreachable through
// `runAxe` and would otherwise be trimmed on faith.
const CWD = process.cwd();
export function trimStackPaths(stack: string): string {
  return (
    stack
      // The app's own frames keep their path relative to the repo โ€” `src/pipeline/lint.ts`
      // is the useful half and says nothing about where the app is installed. `file://`
      // is stripped with the root it prefixes: a bare scheme left behind reads as a path
      // that was not trimmed.
      .replaceAll(`file://${CWD}/`, "")
      .replaceAll(`${CWD}/`, "")
      // A dependency frame keeps `node_modules/<lib>/โ€ฆ`, which is the part that answers
      // which library threw. Greedy and anchored to the start of a token, so a layout that
      // nests one `node_modules` inside another โ€” pnpm's
      // `node_modules/.pnpm/jsdom@25.0.1/node_modules/jsdom/โ€ฆ` โ€” is cut at the LAST of
      // them and names the library once. Lazy, this matched twice on such a path and the
      // second match ate the separator between the segments, gluing them into
      // `node_modules/.pnpmnode_modules/jsdom/โ€ฆ`: no disclosure, but a garbled frame in the
      // one field logged to make a failure chaseable.
      .replace(/(^|[\s(])(?:file:\/\/)?\/\S*node_modules\//gm, "$1node_modules/")
      // Anything still absolute is a frame from neither tree โ€” a global install, a linked
      // dependency, a runtime outside the app โ€” and there is no relative form of it to
      // keep, so it is cut to the file name. Anchored to the start of a token (`(` or
      // whitespace), because a path the rules above already made relative still has
      // slashes inside it: unanchored, this rule ate `node_modules/jsdom/lib/` out of the
      // middle of the frame it had just been asked to preserve.
      .replace(/(^|[\s(])(?:file:\/\/)?\/\S*\//gm, "$1")
  );
}

function failure(where: "parse" | "inject" | "run", message: string, e: unknown): LintResult {
  const err = e instanceof Error ? e : undefined;
  const raw = err?.stack?.split("\n").slice(0, STACK_FRAMES + 1).join("\n");
  const stack = raw === undefined ? undefined : trimStackPaths(raw);
  return {
    // No verdict, whichever step threw. The parse/environment distinction is worth
    // keeping and is kept โ€” in `errorWhere`, where it is a fact about which call threw
    // rather than a value that has to double as the gate's answer. It lived in `ok`
    // before, and the cost was that the environment case reported a pass (#164).
    ok: false,
    error: message,
    errorWhere: where,
    ...(err?.name ? { errorName: err.name } : {}),
    ...(stack ? { errorStack: stack } : {}),
  };
}

// The `lint_error*` half of a log line, shared by all three places a document is linted:
// `assembly`, the review loop's re-lint of a body a correction round produced, and the
// feedback path's re-lint of a body it did not extract. A gate that could not run reads the
// same way whichever module reported it, so one grep finds every occurrence โ€” and a fourth
// caller added later gets the fields rather than a subset of them. Empty when the lint ran,
// so it can be spread unconditionally.
export function lintErrorFields(lint: LintResult): Record<string, string> {
  if (lint.error === undefined) return {};
  return {
    lint_error: lint.error,
    ...(lint.errorWhere ? { lint_error_where: lint.errorWhere } : {}),
    ...(lint.errorName ? { lint_error_name: lint.errorName } : {}),
    ...(lint.errorStack ? { lint_error_stack: lint.errorStack } : {}),
  };
}

// An attribute name that markup can legally be said to have: the XML/HTML name shape, which is
// also every name a CSS selector can carry without escaping. The HTML parser is far more
// permissive than this โ€” it takes anything that is not whitespace, `/`, `>`, `=` or a quote โ€” so
// `9\"` and `1x` are attributes as far as the DOM is concerned, and neither is a name any
// standard, framework or page agent produces. Nothing accessible is spelled outside this shape:
// `aria-*`, `data-*`, `xml:lang`, `role`, `for`, `scope` and `headers` all match it.
//
// Non-ASCII is inside the shape, matching the escape algorithm below, which passes every code unit
// at or above U+0080 through untouched: `data-tรญtulo` is a legal custom attribute and counting it
// as debris would put noise into the one number that answers "is the upstream leak fixed?".
const ATTRIBUTE_NAME = /^[a-zA-Z_:\u0080-\uffff][-a-zA-Z0-9_:.\u0080-\uffff]*$/;

// axe's own `escapeSelector` (node_modules/axe-core/axe.js), which is the CSS.escape algorithm and
// is what axe puts through the selector engine โ€” ported rather than called because jsdom has no
// `CSS.escape` and axe's copy only exists inside a window that has already had axe injected into
// it, which is after the point this has to run. Exported for the test that checks the port and the
// predicate below against the real engine, name by name.
function escapeParts(name: string): { escaped: string; hexEscape: boolean } {
  let out = "";
  // Whether the escaper EMITTED an escape beginning with a decimal digit, which is what the
  // predicate below needs and what reading the finished string cannot tell it: a name that already
  // contains a backslash comes out with `\\` in it, so `aria-label\1` looks exactly like an emitted
  // escape and is not one. That name compiles fine, and removing it would take the
  // `aria-valid-attr` finding on it away โ€” the loss the narrow removal exists to avoid.
  let hexEscape = false;
  for (let i = 0; i < name.length; i++) {
    const c = name.charCodeAt(i);
    if (c === 0) out += "\ufffd";
    else if (
      (c >= 1 && c <= 31) ||
      c === 127 ||
      (i === 0 && c >= 48 && c <= 57) ||
      (i === 1 && c >= 48 && c <= 57 && name.charCodeAt(0) === 45)
    ) {
      // The only branch that emits a HEX escape, and so the only one that can produce the shape
      // the selector engine chokes on. The trailing space terminates the escape.
      const hex = c.toString(16);
      out += `\\${hex} `;
      // Not every hex escape is one: 0x0aโ€“0x0f render as `a`โ€“`f`, which is a letter and compiles.
      if (hex[0]! >= "0" && hex[0]! <= "9") hexEscape = true;
    } else if (i === 0 && name.length === 1 && c === 45) out += `\\${name[i]}`;
    else if (c >= 128 || c === 45 || c === 95 || (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122))
      out += name[i];
    else out += `\\${name[i]}`;
  }
  return { escaped: out, hexEscape };
}

export function escapeAttributeName(name: string): string {
  return escapeParts(name).escaped;
}

// Whether a selector carrying this attribute name is one nwsapi cannot compile โ€” i.e. whether this
// name, and not merely a malformed one, is what takes the rule set offline.
//
// nwsapi compiles a selector into JavaScript source and evaluates it with `Function`, whose body is
// strict, and it splices an attribute name in with its CSS escapes still on it. A backslash
// followed by a decimal digit is an octal (or non-octal-decimal) escape there, which is a
// SyntaxError in strict mode โ€” so the test is exactly that shape and nothing broader.
// `aria-label\"note\"` compiles; `\31 x` does not.
//
// nwsapi refuses one more shape, deliberately not tested for here: a name ending in a backslash,
// which `[c:\\]` chokes on. It is out because axe never builds that selector โ€” an escaped attribute
// name only ever goes into `[name="value"]`, and `[c:\\=""]` compiles โ€” so removing those names
// would cost findings and rescue nothing. The test probes the `="value"` form for that reason.
//
// Read off what the escaper DID rather than by matching the string it returned, because the two
// differ on one shape: a backslash already in the name is emitted as `\\`, so `aria-label\1` ends
// with a backslash followed by a digit without containing an escape. It compiles, no rule set dies
// on it, and `aria-valid-attr` reports it โ€” so a pattern match here would remove the very kind of
// name the narrowing is for.
//
// Every name outside `ATTRIBUTE_NAME` is debris and gets counted, but only these are REMOVED,
// because removing an attribute takes rules away with it (see `malformedAttributes`).
export function breaksSelectorEngine(name: string): boolean {
  return escapeParts(name).hexEscape;
}
// How many of the names are kept, and how much of each. Same bound and the same reason as the node
// excerpts below: this is content out of a user's document and it goes in a log line. Short,
// because a name is an identifier and the leaks that produce these are recognisable in a few
// characters (`1\"`, `9\"`); a name longer than this is a run of leaked prose, and that it was long
// is the fact worth keeping, not the prose.
export const MAX_MALFORMED_NAMES = 3;
export const MALFORMED_NAME_CHARS = 40;

// Count the attributes in the document about to be linted whose names no valid markup produces,
// and take out the ones that would stop axe running at all.
//
// The removal exists because two such attributes take the accessibility check offline for the
// WHOLE document (#257). axe needs a unique CSS path for each element it reports; where an id is
// unusable and a similar sibling has to be disambiguated it enumerates attributes instead, and a
// name beginning with a digit escapes to its hex codepoint โ€” `9\"` becomes `\39 \\\"`. nwsapi
// compiles that selector into JavaScript source, where `\39` is an octal escape, which is a
// SyntaxError in strict mode. The rule set dies with it: measured on a real corpus, 6 delivered
// 25-page documents went entirely unlinted โ€” every defect on all 150 pages unexamined โ€” because of
// an attribute on an `<hr>`. Since #167 that is at least reported as no verdict rather than as a
// pass, but the document still ships and nothing defended the check itself.
//
// It takes TWO such elements, because axe builds the attribute selector only when it must
// disambiguate. In a 25-page document with a page-break marker per page, two corrupted markers is
// the ordinary case rather than the unlucky one โ€” which is why 6 of the 7 corrupted documents found
// were unlintable and only one squeaked through.
//
// COUNTED WIDELY, REMOVED NARROWLY, and the gap between the two is the point. Removing an attribute
// takes the rules that read it away with it, and some rules fire BECAUSE a name is malformed: a
// page answer that loses one quote (`aria-label"Note"`) is a single attribute named
// `aria-label"note"`, which `aria-valid-attr` reports โ€” critical, wcag2a, wcag412 โ€” because its
// matcher is a prefix test on the raw name. Measured on this repo's own `runAxe`: with that
// attribute removed, a document that failed the gate on a critical WCAG A rule passes it clean,
// with a number on a log line as the only trace, and `/v1/quality` counts it among
// `documents_linted` with no rule against it. So removal is limited to the names the selector
// compiler actually chokes on (`breaksSelectorEngine`, which is the escape shape and nothing
// broader), and every other malformed name is left where it is and counted. Nothing is given up:
// a name that escapes to `\<hex>` begins with a digit, a control character or `-`, so it can never
// be an `aria-` or `data-` attribute, and no rule reads it.
//
// Safe because of WHERE it happens: this is the throwaway DOM `runAxe` parsed from a string for
// its own use, and nothing is ever serialized out of it. The delivered document is the string the
// caller still holds, and it keeps every byte it had โ€” that this pipeline does not rewrite a
// user's markup on the way past is the same rule `anchors.ts` declines reserialization for.
//
// What a removal does cost is evidence, and that is the reason for reporting rather than only
// doing it: a violation's `html` excerpt is axe's serialization of the node, and jsdom's serializer
// does NOT drop these attributes (verified โ€” `<hr 9\"="" id="a">` round-trips through `outerHTML`
// intact), so before this the excerpt was the one place the debris was visible to anybody. A count
// and a sample of the names replaces that with something a person can grep for.
function malformedAttributes(document: Document): { count: number; removed: number; names: string[] } {
  let count = 0;
  let removed = 0;
  // Kept apart so the sample can lead with the names that changed what axe was shown; a document
  // with debris on every page would otherwise fill three slots before reaching the one that
  // mattered. Each list is bounded on its own, because a name is a string out of a user's document
  // and there can be thousands of them.
  const removedNames: string[] = [];
  const keptNames: string[] = [];
  for (const element of document.querySelectorAll("*")) {
    // Snapshotted, because removing an attribute changes the live list this is walking.
    for (const name of element.getAttributeNames()) {
      if (ATTRIBUTE_NAME.test(name)) continue;
      count++;
      const breaks = breaksSelectorEngine(name);
      if (breaks) {
        element.removeAttribute(name);
        removed++;
      }
      const into = breaks ? removedNames : keptNames;
      if (into.length < MAX_MALFORMED_NAMES) {
        into.push(name.length <= MALFORMED_NAME_CHARS ? name : `${name.slice(0, MALFORMED_NAME_CHARS)}โ€ฆ`);
      }
    }
  }
  return { count, removed, names: [...removedNames, ...keptNames].slice(0, MAX_MALFORMED_NAMES) };
}

// The `malformed_attribute*` half of a log line, shared for the reason `lintErrorFields` is: three
// modules lint a body, and debris that reads differently depending on which of them reported it is
// debris nobody greps for. Empty when the document was ordinary, so it can be spread
// unconditionally โ€” the same convention as above, where a field that is present means something.
//
// Followed at each site the way `lintErrorFields` is: spread onto the line that stage already logs
// where there is one (`assembly`), and on its own event where the only existing line is the one that
// fires when the lint FAILED (`lint_debris` beside `lint_unavailable`, since a document can have
// debris and still be linted โ€” that is now the ordinary outcome).
export function lintDebrisFields(lint: LintResult): Record<string, string | number | string[]> {
  if (!lint.malformedAttributes) return {};
  return {
    malformed_attributes: lint.malformedAttributes,
    ...(lint.malformedAttributesRemoved ? { malformed_attributes_removed: lint.malformedAttributesRemoved } : {}),
    ...(lint.malformedAttributeNames?.length ? { malformed_attribute_names: lint.malformedAttributeNames } : {}),
  };
}

// How many of a rule's nodes are kept, and how much of each one's markup.
//
// Both are bounds on a PROMPT, which is what makes them small. `lintSummary` puts this text in
// every Reader chunk of every review round, so a rule with 400 nodes โ€” `heading-order` on a
// badly levelled 25-page document โ€” would otherwise spend more of the round's window on
// selectors than on the document. A handful of examples is what makes a rule findable; the
// count, which is unbounded and exact, is what says how much of it there is.
//
// The character bound is needed even though axe truncates for its own reasons: measured, an
// element with 40 attributes came back as a 279-character start tag, and nothing bounds the
// number of attributes a page agent can write.
export const MAX_EXAMPLE_NODES = 3;
export const NODE_HTML_CHARS = 200;

// One line, because each example is one line of a prompt list and axe's `html` keeps the
// document's newlines (measured: `<a href="#q">\n  </a>`). Folding is why the excerpt is an
// identifier and not a string to match on: the selector is the thing that locates the element.
function excerpt(html: string): string {
  const folded = html.replace(/\s+/g, " ").trim();
  return folded.length <= NODE_HTML_CHARS ? folded : `${folded.slice(0, NODE_HTML_CHARS)}โ€ฆ`;
}

// axe's `target` is an ARRAY: one selector per frame on the way to the element, and a nested
// array for a shadow root. This pipeline lints one frameless, shadowless document, so every
// measured target is a single selector โ€” but the field is typed for the general case and a
// value that is not what was measured must not reach a prompt as `[object Array]`. Flattened
// and joined the way axe's own reporters join a frame path.
function selector(target: unknown): string {
  const parts = Array.isArray(target) ? (target as unknown[]).flat(Infinity) : [target];
  return parts.filter((p): p is string => typeof p === "string" && p !== "").join(" >> ");
}

// The first MAX_EXAMPLE_NODES nodes that identify an element at all.
//
// A node that yields neither a selector nor any markup is skipped rather than kept as a pair of
// empty strings, which would reach the prompt as a bullet with nothing on either side of it โ€”
// a line telling the Reader an element exists and refusing to say which. Unreachable through
// axe in this environment (every measured node has both), so this is about what the prompt is
// allowed to contain rather than about a case that happens: the fields are read as `unknown`
// because they cross a realm boundary, and something narrowed out of a foreign value must not
// leave a line behind. Taken in order, so a rule whose first node is unusable still contributes
// its next one instead of listing one fewer.
//
// Exported for the same reason `trimStackPaths` is: the shapes it is written for cannot be
// provoked through `runAxe` in this environment, so a test has to hand them over directly or
// they are handled on faith.
export function exampleNodes(nodes: { target?: unknown; html?: unknown }[]): LintNode[] {
  const out: LintNode[] = [];
  for (const n of nodes) {
    if (out.length === MAX_EXAMPLE_NODES) break;
    const target = selector(n.target);
    const html = excerpt(typeof n.html === "string" ? n.html : "");
    if (target || html) out.push({ target, html });
  }
  return out;
}

// Whether a language tag's primary subtag is a language, answered out of the SAME list the gate
// validates against: `axe.utils.validLangs()` walks the IANA registry's language range, which axe
// ships as a trie, and returns all 8,268 of them. So this is not a proxy for what `html-lang-valid`
// accepts โ€” it is that rule's own data, read from the same pinned dependency (see the note on
// `runAxe` below for why the pin exists).
//
// It lives here rather than in assembly.ts, which asks the question, because this module already
// owns the axe dependency and "what the linter counts as a language" is the linter's fact.
//
// Two things about how it is read. `axe.utils` is a runtime export that axe's own .d.ts does not
// declare, hence the cast; and it is not covered by axe's semver, so it is read once behind a guard
// instead of trusted. If a future axe drops it the fallback is `Intl.DisplayNames`, CLDR display
// data, which agrees with axe on every tag either can name but has no name for part of the obscure
// end of ISO 639-3 that axe accepts (measured: 10 of 66 โ€” `aaa`, `aab`, `abt`, `lns`, `ttj` and
// the like). That direction costs a document in a small language its root label rather than putting
// a violation on it, which is the direction this guard has chosen at every step โ€” and
// test/document-lang.test.ts asserts the axe list is the one in use, so the downgrade fails the
// suite rather than quietly costing those documents anything.
const AXE_LANGUAGES: ReadonlySet<string> | null = (() => {
  try {
    const utils = (axe as unknown as { utils?: { validLangs?: () => string[] } }).utils;
    const langs = utils?.validLangs?.();
    // Length-checked, not just type-checked: an empty or truncated list would refuse every
    // language on earth, and refusing everything is indistinguishable from the guard working.
    return Array.isArray(langs) && langs.length > 1000 ? new Set(langs) : null;
  } catch {
    return null;
  }
})();
// The fallback, built on first use and not at import. Two reasons, and the second is the
// one that matters: `Intl.DisplayNames` is absent on a runtime built `--without-intl`, and
// constructing it at module scope raises a TypeError while IMPORTING this file โ€” so a
// missing ICU would stop the pipeline module from loading at all, which is the opposite of
// what guarding `AXE_LANGUAGES` buys. Lazily, the same runtime degrades instead. (The
// cheaper reason: on the primary path nothing ever consults CLDR.)
//
// `null` means asked-and-unavailable, distinct from `undefined` for not-yet-asked, so the
// constructor is attempted once either way.
let languageNames: Intl.DisplayNames | null | undefined;

// Exported as its own function so the fallback is reachable from a test. `isKnownLanguage`
// takes the axe branch in every environment the suite can construct โ€” the list is read at
// import from a pinned dependency and there is no seam to remove it โ€” so without this the
// path that answers on the day an axe bump drops `utils` would be the one path never run.
export function cldrKnowsLanguage(subtag: string): boolean {
  if (languageNames === undefined) {
    try {
      languageNames = new Intl.DisplayNames(["en"], { type: "language", fallback: "none" });
    } catch {
      languageNames = null;
    }
  }
  // No list and no CLDR: every value is refused, and a refused value costs the document its
  // derived root language and nothing else (bodyLang falls back to `en`). That is the safe
  // direction of the two โ€” see the note above `preferredTag` in assembly.ts โ€” so it is left
  // to happen quietly rather than raised here, where it would fail runs over an attribute.
  if (!languageNames) return false;
  try {
    return Boolean(languageNames.of(subtag));
  } catch {
    // `of` throws on anything that is not a well-formed language id.
    return false;
  }
}

// Exported for the test that pins WHICH of the two sources answered.
export const languageListSource = AXE_LANGUAGES ? "axe" : "cldr";
export function isKnownLanguage(subtag: string): boolean {
  if (!subtag) return false;
  if (AXE_LANGUAGES) return AXE_LANGUAGES.has(subtag.toLowerCase());
  return cldrKnowsLanguage(subtag);
}

// Validate that the document parses and that basic accessibility lint passes
// (axe-core in headless mode). We run axe inside a jsdom realm. If axe cannot run in this
// environment the session continues rather than failing โ€” but with no verdict rather than
// with a passing one (`ok: false`, no `violations`; see LintResult), because a document
// nothing checked is not a document nothing was wrong with. Either way the result is
// surfaced to the Reader as input.
//
// One shape of that failure was a property of the DOCUMENT and reachable from ordinary
// output โ€” see test/lint-never-ran.test.ts, which builds it in five elements. jsdom's
// selector engine compiles a selector into JavaScript source, and it splices an attribute
// NAME into a string literal without converting the CSS escapes in it, so an attribute
// whose name begins with a digit (`1x=""`, which the HTML parser accepts and which a page
// of leaked JSON produces by the dozen) reaches V8 as `"\31 x"` โ€” an octal escape, which
// is a SyntaxError in strict mode, which the compiled selector is. That is the error #144
// and #164 both saw, and it killed the whole run of the rule set: one such attribute
// anywhere in a 25-page document and the gate had no answer for any of it.
//
// `malformedAttributes` now takes exactly those names out of the lint's own copy of the
// document before axe walks it, so the gate survives markup it cannot describe (#257), and
// counts every malformed name โ€” including the ones it leaves alone, which are the majority and
// which rules still read. The reporting below is what remains for whatever fails next: the
// defence is specific to one cause, and a gate that returns no verdict has to stay chaseable.
//
// `axe-core` and `jsdom` are pinned to exact versions in package.json rather than
// carried on a caret range, and this function is the reason. It is a GATE: what it
// reports decides whether a document ships with a violation, its rule set is tuned
// against axe internals below (which rule claims which element, which findings land in
// `incomplete`), and the same rule ids are what `GET /v1/quality` reports deployment-
// wide. On a range, that behaviour can change on any redeploy with no commit to point
// at โ€” including a change that makes a failure like #144 appear or disappear โ€” and an
// operator investigating a checkout of the same sha could not be sure they had the same
// linter. equalify-iris-bench ports this configuration deliberately so its accuracy
// numbers mean the same thing as Iris's, which only holds if both can name one version.
export async function runAxe(html: string): Promise<LintResult> {
  let dom: JSDOM;
  try {
    // Swallow jsdom's not-implemented noise (e.g. canvas getContext, which the
    // disabled color-contrast rule would otherwise trigger).
    const virtualConsole = new VirtualConsole();
    dom = new JSDOM(html, { runScripts: "outside-only", pretendToBeVisual: true, virtualConsole });
  } catch (e) {
    return failure("parse", `document failed to parse: ${(e as Error).message}`, e);
  }

  // Before axe sees a tree, and reported on every result below โ€” including the failures, since a
  // document that broke the gate some OTHER way is exactly the one whose debris count is worth
  // reading. A `parse` failure above cannot carry it: there is no document to have looked at.
  const debris = malformedAttributes(dom.window.document);
  const found: Pick<LintResult, "malformedAttributes" | "malformedAttributesRemoved" | "malformedAttributeNames"> =
    debris.count
      ? {
          malformedAttributes: debris.count,
          ...(debris.removed ? { malformedAttributesRemoved: debris.removed } : {}),
          malformedAttributeNames: debris.names,
        }
      : {};

  // The two node fields are `unknown` rather than `string`/`string[]`: they cross a realm
  // boundary out of jsdom and nothing here validates them, so they are narrowed where they are
  // read instead of asserted here.
  type AxeNode = { target?: unknown; html?: unknown };
  type AxeIssue = { id: string; impact: string | null; description: string; nodes: AxeNode[] };
  type AxeWindow = {
    axe: { run: (ctx: unknown, opts: unknown) => Promise<{ violations: AxeIssue[]; incomplete: AxeIssue[] }> };
  };

  try {
    const { window } = dom;
    // Inject the axe-core library source into the jsdom realm and run it there.
    //
    // The injection is its own step, and its own catch, because it is a different
    // diagnosis from the rule pass failing: evaluating axe's source is the pipeline
    // compiling ITS dependency, which cannot depend on the document at all, while
    // `axe.run` walks the document this run produced. #144 arrived as a JavaScript
    // SyntaxError with nothing in it to say which of the two had happened โ€” and those
    // two answers point at a version bump and at a page of HTML respectively.
    try {
      window.eval(axe.source);
    } catch (e) {
      return {
        ...failure("inject", `axe-core could not run in this environment: ${(e as Error).message}`, e),
        ...found,
      };
    }
    const w = window as unknown as AxeWindow;
    const results = await w.axe.run(window.document, {
      runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] },
      rules: {
        // Output is content-only with no styling, so color contrast is
        // out of scope and cannot be assessed without rendering anyway.
        "color-contrast": { enabled: false },
        // Enabled BY NAME because the tag filter above excludes it: WCAG 2.2
        // dropped 4.1.1, so axe tags `duplicate-id` `wcag2a-obsolete` and
        // `deprecated`. Obsolete as a conformance criterion is not the same as
        // harmless here. This document is assembled from independently extracted
        // pages, so a duplicate id is the specific defect that arises from
        // concatenation, and it breaks navigation rather than conformance: two
        // `id="fn-1"` means every `href="#fn-1"` reaches the first one, so a
        // footnote reference on a later page silently goes to the wrong note.
        // `duplicate-id-aria` (which IS wcag2a) does not cover it โ€” that rule
        // fires for ids referenced from ARIA attributes, not from an `href`.
        //
        // assembleBody namespaces each page's ids, so this is a backstop, not the
        // fix: the review loop re-lints after the Copy Editor has rewritten the
        // whole body, and that is a rewrite by a model that can reintroduce a
        // collision the assembler had already resolved. It is also what reports the
        // collision on a page whose rewrite was abandoned (anchors.ts
        // `skipped_pages`), and the same-page duplicate no prefix can fix.
        //
        // Both obsolete halves are needed, because axe splits duplicate ids across
        // three rules by what the element IS, and each rule deliberately skips the
        // others' elements (`duplicateIdMiscMatches` requires that no element with the
        // id is focusable; `duplicateIdActiveMatches` requires that one is; both first
        // require that the id is not an accessibility reference target). So with only
        // `duplicate-id` enabled, two `<li id="x">` are reported and two `<a id="x">`
        // or two `<input id="x">` come back clean โ€” verified in this environment, and
        // pinned by a test, since which rule claims which element is an axe internal
        // that a version bump can move. Active elements are the ones that matter most
        // here: a duplicate id on an `<input>` is what makes a `<label for>` name the
        // wrong field.
        "duplicate-id": { enabled: true },
        "duplicate-id-active": { enabled: true },
        // The third rule needs no enabling โ€” `duplicate-id-aria` is current (`wcag2a`)
        // and arrives via the tag filter โ€” but it is `reviewOnFail`, so axe puts its
        // findings in `incomplete` rather than `violations`. Handled below.
        //
        // Enabled BY NAME on the same argument as the two above, one criterion over:
        // axe tags `heading-order` `best-practice`, so the tag filter drops it, and a
        // level this document skips on the way down is indeed not a conformance
        // failure. It is still the defect this pipeline is most exposed to. Headings
        // are how a screen-reader user navigates a long document, the levels are
        // decided one page at a time by a model looking at type size, and nothing
        // downstream could see the result: the Reader Agent never gets the source
        // images (READER_SYSTEM in review.ts), so it cannot know which heading the
        // page subordinated to which, and until now this gate passed an <h2> followed
        // by an <h4> with zero violations. `agents/page.md` has told the page agent
        // not to skip a level since #96, and #114 reported one shipped anyway โ€” which
        // is the case for checking the output rather than only asking for it.
        //
        // The rule earns the exception by being decidable from the document alone. It
        // fires only where a level goes DOWN by more than one โ€” verified in this
        // environment and pinned by test/lint-heading-order.test.ts, because which
        // shapes axe's `after` function reports is an internal a version bump can
        // move. In particular it stays quiet on the three shapes this pipeline
        // produces on purpose: a body that opens at <h2> or <h3>, because a page may
        // be a subsection of a heading on a page the extractor was never shown; a
        // heading that returns to an outer level after a run of subsections; and a
        // document with one heading or none. What it cannot see is the other half of
        // the same bug โ€” an <h2> that should have been an <h3> is a level the page
        // decided, not a gap in the sequence โ€” so this narrows the prompt's job
        // rather than replacing it.
        //
        // Iris's own quality reporting has used this rule as its worked example since
        // the tally shipped (`Store.qualityStats` in store/db.ts, docs/API.md "Quality
        // tally") and could not once have reported it: every rule id in `run_signals`
        // comes from this call, so a rule the tag filter drops is one the weekly report
        // can never raise, however often the output breaks it.
        "heading-order": { enabled: true },
        // Enabled BY NAME on the same argument again, for the defect #251 measured: 18% of
        // page answers emit a `<main>` of their own, `wrapDocument` puts the assembled body
        // inside one, and the result ships a `main` inside a `main` โ€” which takes away the
        // one landmark a screen-reader user jumps to in order to skip the furniture. axe has
        // three rules for it and tags all three `best-practice`, so this gate reported the
        // document clean; measured in this environment, a body of `<main><h2>โ€ฆ</h2></main>`
        // came back with zero violations before these two lines.
        //
        // `landmarks.ts` removes the tags at assembly and after every editor round, so what
        // reaches here is the residue that rewrite declines: half a wrapper (a `<main>` with
        // no `</main>`, whose extent is whatever the parser decides), and a `role="main"` on
        // an element that was never a `<main>`, which is a role a model chose and not this
        // pipeline's to delete. Same relationship as `duplicate-id` has to the anchor
        // namespacing above โ€” the rewrite is the fix and the rule is the check that it worked.
        //
        // Both rules, because they answer different questions and either can fire alone:
        // measured, `<div role="main">` in the body trips both, while a `<main>` nested inside
        // a fragment's own `<section>` is not a duplicate of anything if the strip already
        // took the other one, and `landmark-main-is-top-level` is what reports it.
        "landmark-no-duplicate-main": { enabled: true },
        "landmark-main-is-top-level": { enabled: true },
        // The third rule axe fires on that document, `landmark-unique`, is deliberately NOT
        // enabled, and this is the `heading-order` caution one rule over: it is the one that
        // does not survive contact with a multi-page document. Measured in this environment,
        // it fires on two `<nav>` elements with no accessible name, on two `<aside>`, and on
        // two `<section aria-label="Notes">` โ€” a table-of-contents page and a sidebar are
        // repeatable page furniture, so each of those is ordinary output from a document that
        // prints the same thing twice, not a defect. It is also unreliable on the case it
        // would be enabled for: a `<main aria-label="Page 1">` inside the shell's unnamed
        // `main` has a different accessible name, so the rule stays quiet on exactly the
        // nested main that carries a label. Two rules that always fire, and no third that
        // sometimes fires on purpose-built output.
      },
    });
    // `duplicate-id-aria` is the one duplicate-id rule that is still a live WCAG
    // criterion (4.1.2), and it is the only one that fires for an id something actually
    // references โ€” `<label for>`, `aria-describedby`. It is also `reviewOnFail`, so axe
    // reports it as `incomplete` and not as a violation, which meant the case with the
    // clearest user harm was the one this gate could not see: two `<input id="q1">`
    // under one `<label for="q1">` came back with zero violations even after enabling
    // both obsolete rules. A duplicate id needs no human judgement to confirm โ€” the ids
    // are either equal or they are not โ€” so this rule's incomplete results are promoted
    // to violations. Only this rule: the rest of `incomplete` is genuinely
    // can't-tell-without-rendering (contrast, off-screen content) and promoting it
    // would fail every run.
    const promoted = results.incomplete.filter((v) => v.id === "duplicate-id-aria");
    const violations = [...results.violations, ...promoted].map((v) => ({
      id: v.id,
      impact: v.impact,
      description: v.description,
      nodes: v.nodes.length,
      // Bounded HERE rather than where the prompt is built, so nothing downstream holds an
      // unbounded slice of the document's markup โ€” this result is logged, and the examples are
      // the one part of it that is content from a user's file. (`/v1/quality` takes the count
      // and nothing else; see orchestrator.ts.)
      examples: exampleNodes(v.nodes),
    }));
    return { ok: violations.length === 0, violations, ...found };
  } catch (e) {
    return {
      ...failure("run", `axe-core could not run in this environment: ${(e as Error).message}`, e),
      ...found,
    };
  } finally {
    // `close()` walks the tree recursively, so a pathologically deep document overflows the
    // stack in here โ€” and a throw from a `finally` replaces whatever the `try` returned,
    // including the graceful degradation above it. That turned a document assembly had
    // already decided to deliver (anchors.ts `MAX_NESTING` skips a page too deep to rewrite,
    // so its nesting reaches the delivered body) into a failed session, one function after
    // the module that made the decision. Cleanup cannot be the thing that fails the run:
    // what it releases early is otherwise left to the collector.
    try {
      dom.window.close();
    } catch {
      // Deliberately empty: see above.
    }
  }
}