๐Ÿ“ฆ EqualifyEverything / equalify-iris-bench

๐Ÿ“„ lint.mjs ยท 116 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// A local axe re-lint of each delivered document.
//
// This exists because of a gap in what a session reports. Iris lints during the
// review loop and again after it, but only the DURING-loop results reach
// `log.jsonl` (as `lint` events). The FINAL post-review lint โ€” the one that
// describes the document actually handed back โ€” is written to the deployment's
// `run_signals` table and surfaces only in aggregate, through `GET /v1/quality`.
// There is no per-document endpoint for it. So the only way to say "this document
// came back with two duplicate ids" is to re-run axe on the delivered HTML here.
//
// The configuration below is a deliberate port of the deployment's
// `src/pipeline/lint.ts`, not an independent opinion about what to check. If it
// drifts, this harness stops measuring Iris and starts measuring a different
// linter โ€” so the four decisions that make Iris's config unusual are reproduced
// exactly, and each is annotated with why Iris made it:
//
//   * The WCAG tag filter, verbatim.
//   * `color-contrast` off โ€” the output is content-only with no styling, and
//     contrast cannot be assessed without rendering.
//   * `duplicate-id` and `duplicate-id-active` ON BY NAME. The tag filter excludes
//     them (WCAG 2.2 dropped 4.1.1, so axe tags them obsolete), but a duplicate id
//     is the specific defect that arises from assembling a document out of
//     independently extracted pages. Both halves are needed: axe splits duplicate
//     ids across three rules by what the element is, and each skips the others'
//     elements.
//   * `duplicate-id-aria`'s `incomplete` results promoted to violations โ€” that rule
//     is `reviewOnFail`, so its findings never appear under `violations`, and it is
//     the one that catches the case with the clearest harm (two `<input id="q1">`
//     under one `<label for="q1">`).
//
// Version fidelity matters as much as configuration: which rule claims which
// element is an axe internal that a version bump can move. package.json pins
// axe-core and jsdom to the same versions the deployment resolves.

import { JSDOM, VirtualConsole } from "jsdom";
import axe from "axe-core";

export const AXE_VERSION = axe.version;

export async function runAxe(html) {
  let dom;
  try {
    const virtualConsole = new VirtualConsole();
    dom = new JSDOM(html, { runScripts: "outside-only", pretendToBeVisual: true, virtualConsole });
  } catch (e) {
    return { ok: false, violations: [], error: `document failed to parse: ${e.message}` };
  }

  try {
    const { window } = dom;
    window.eval(axe.source);
    const results = await window.axe.run(window.document, {
      runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"] },
      rules: {
        "color-contrast": { enabled: false },
        "duplicate-id": { enabled: true },
        "duplicate-id-active": { enabled: true },
      },
    });
    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,
    }));
    return { ok: violations.length === 0, violations };
  } catch (e) {
    // Iris degrades to a pass here rather than failing a run. The harness keeps the
    // same verdict but records the error, because at corpus scale "axe could not
    // run" and "the document is clean" must not be summarized as the same thing.
    return { ok: true, violations: [], error: `axe-core could not run in this environment: ${e.message}` };
  } finally {
    try {
      dom.window.close();
    } catch {
      // Deliberately empty, for the reason the deployment gives: close() recurses,
      // a deep document overflows the stack in here, and a throw from finally would
      // replace the result the try block already decided on.
    }
  }
}

// Structural counts, for the shape of what came back rather than its conformance.
// A document that lints clean because it is nearly empty is a failure mode axe
// cannot see, and "extraction quietly dropped half the pages" is exactly the kind
// of regression a 2000-document corpus is worth running to find.
export function shape(html) {
  const count = (re) => (html.match(re) ?? []).length;
  return {
    bytes: Buffer.byteLength(html),
    // Text length after tags, as a floor on how much content survived.
    text_chars: html
      .replace(/<script[\s\S]*?<\/script>/gi, "")
      .replace(/<style[\s\S]*?<\/style>/gi, "")
      .replace(/<[^>]+>/g, " ")
      .replace(/\s+/g, " ")
      .trim().length,
    headings: count(/<h[1-6][\s>]/gi),
    h1: count(/<h1[\s>]/gi),
    tables: count(/<table[\s>]/gi),
    lists: count(/<[uo]l[\s>]/gi),
    images: count(/<img[\s>]/gi),
    // An <img> with no alt attribute at all is an axe violation; alt="" is a valid
    // decorative marker. Counted separately because a page of scanned figures
    // marked decorative is a judgement call worth seeing, not a rule failure.
    images_alt_empty: count(/<img[^>]*\salt=(""|'')/gi),
    links: count(/<a[\s>]/gi),
    // Iris namespaces each page's ids; a surviving fragment link that resolves
    // nowhere is a broken cross-reference the assembler did not catch.
    anchors: count(/href="#/gi),
    lang: /<html[^>]*\slang=/i.test(html),
    title: /<title>\s*\S/i.test(html),
  };
}