@binclusive/a11y 0.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.
Files changed (80) hide show
  1. package/README.md +285 -0
  2. package/bin/a11y.mjs +36 -0
  3. package/bin/diff-scope.mjs +29 -0
  4. package/data/baseline-rules.json +892 -0
  5. package/package.json +68 -0
  6. package/src/agent-lane.ts +138 -0
  7. package/src/agents-block.ts +157 -0
  8. package/src/baseline/gen-baseline.ts +166 -0
  9. package/src/cli.ts +1026 -0
  10. package/src/collect-dom.ts +119 -0
  11. package/src/collect-liquid.ts +103 -0
  12. package/src/collect-swift.ts +227 -0
  13. package/src/collect-unity.ts +99 -0
  14. package/src/collect.ts +54 -0
  15. package/src/commands.ts +314 -0
  16. package/src/config-scan.ts +177 -0
  17. package/src/contract.ts +355 -0
  18. package/src/core.ts +546 -0
  19. package/src/detect-stack.ts +207 -0
  20. package/src/diff-scope-cli.ts +12 -0
  21. package/src/diff-scope.ts +150 -0
  22. package/src/emit-contract.ts +181 -0
  23. package/src/enforce.ts +1125 -0
  24. package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
  25. package/src/evidence.ts +308 -0
  26. package/src/github-identity.ts +201 -0
  27. package/src/hook.ts +242 -0
  28. package/src/impact-gate.ts +107 -0
  29. package/src/imports-resolve.ts +248 -0
  30. package/src/index.ts +183 -0
  31. package/src/liquid-ast.ts +203 -0
  32. package/src/liquid-rules.ts +691 -0
  33. package/src/mcp.ts +363 -0
  34. package/src/module-scope.ts +137 -0
  35. package/src/phone-home.ts +470 -0
  36. package/src/pr-comment.ts +250 -0
  37. package/src/pr-summary-cli.ts +182 -0
  38. package/src/pr-summary.ts +291 -0
  39. package/src/registry.ts +605 -0
  40. package/src/reporter/contract.ts +154 -0
  41. package/src/reporter/finding.ts +87 -0
  42. package/src/reporter/github-adapter.ts +183 -0
  43. package/src/reporter/null-adapter.ts +51 -0
  44. package/src/reporter/registry.ts +22 -0
  45. package/src/reporter-cli.ts +48 -0
  46. package/src/resolve-components.ts +579 -0
  47. package/src/runner/budget.ts +90 -0
  48. package/src/runner/codegraph-lookup.test.ts +93 -0
  49. package/src/runner/codegraph-lookup.ts +197 -0
  50. package/src/runner/index.ts +86 -0
  51. package/src/runner/lookup.ts +69 -0
  52. package/src/runner/provider.ts +72 -0
  53. package/src/runner/providers/anthropic.ts +168 -0
  54. package/src/runner/providers/openai.ts +191 -0
  55. package/src/runner/reasoner.ts +73 -0
  56. package/src/runner/reasoning/index.ts +53 -0
  57. package/src/runner/reasoning/prompt.ts +200 -0
  58. package/src/runner/reasoning/react.ts +149 -0
  59. package/src/runner/reasoning/shopify.ts +214 -0
  60. package/src/runner/reasoning/skills-reasoner.ts +117 -0
  61. package/src/runner/reasoning/types.ts +99 -0
  62. package/src/runner/runner.ts +203 -0
  63. package/src/sarif.ts +148 -0
  64. package/src/source-identity.ts +0 -0
  65. package/src/source-trace.ts +814 -0
  66. package/src/suggest.ts +328 -0
  67. package/src/suppression-ranges.ts +354 -0
  68. package/src/suppressor-map.ts +189 -0
  69. package/src/suppressors.ts +284 -0
  70. package/src/tsconfig-aliases.ts +155 -0
  71. package/src/unity-ast.ts +331 -0
  72. package/src/unity-findings.ts +91 -0
  73. package/src/unity-guid-registry.ts +82 -0
  74. package/src/unity-label-resolve.ts +249 -0
  75. package/src/unity-rule-color-only.ts +127 -0
  76. package/src/unity-rule-missing-label.ts +156 -0
  77. package/src/unity-rules-baseline.ts +273 -0
  78. package/src/wcag-map.ts +35 -0
  79. package/src/wcag-tags.ts +35 -0
  80. package/src/workspace-resolve.ts +405 -0
@@ -0,0 +1,691 @@
1
+ /**
2
+ * The Liquid structural-absence rule set — L2 of the Shopify/Liquid producer (#47).
3
+ *
4
+ * Each rule walks the L1 AST and fires ONLY on a structural absence that is
5
+ * decidable from source: an attribute that does not exist, an interactive control
6
+ * with no name source anywhere in its subtree. A rule never fires when the thing it
7
+ * checks is present-but-dynamic (`alt="{{ image.alt }}"`) — L1's classifier draws
8
+ * that line and this layer honors it. That is the precision invariant: a false
9
+ * positive on a render-time value is the failure mode that gets the tool uninstalled.
10
+ *
11
+ * What is deliberately NOT here: every runtime/computed check (see
12
+ * {@link RUNTIME_EXCLUSIONS}). Color contrast, nested-interactive, required-children,
13
+ * computed ARIA roles — those need a rendered DOM and belong to `check-url` (axe),
14
+ * not a static source pass. The live audits on real Shopify themes confirmed those
15
+ * classes dominate at runtime; this layer owns the structural floor only.
16
+ *
17
+ * Shape: each rule maps to a WCAG SC (the bridge below, the analog of `wcag-tags.ts`)
18
+ * and emits the canonical {@link Finding}. `file` and `enforcement` come from the
19
+ * caller's {@link LiquidRuleContext} (L3 supplies them from the file walk + config);
20
+ * the rule owns `ruleId`, `message`, `wcag`, and the source `line`.
21
+ *
22
+ * Prior art: `.patterns/theme-check-common/html-attribute-checks.md` (Shopify's own
23
+ * `ImgWidthAndHeight` — "`.find()` returned undefined → report").
24
+ */
25
+
26
+ import { NodeTypes, type DocumentNode, type LiquidHtmlNode } from "@shopify/liquid-html-parser";
27
+ import type { EnforcementLevel } from "./config-scan";
28
+ import type { Finding } from "./core";
29
+ import {
30
+ attr,
31
+ elementName,
32
+ isHtmlElement,
33
+ rawSourceOf,
34
+ spanOf,
35
+ type HtmlElementNode,
36
+ } from "./liquid-ast";
37
+
38
+ /** What L3 supplies so a rule can finalize a {@link Finding} it can't know itself. */
39
+ export interface LiquidRuleContext {
40
+ /** The `.liquid` file path the finding is anchored in. */
41
+ readonly file: string;
42
+ /** The full source string — used to map a node's byte offset to a 1-based line. */
43
+ readonly source: string;
44
+ /** The enforcement level for this file, from the governing config. */
45
+ readonly enforcement: EnforcementLevel;
46
+ /**
47
+ * File-scoped indexes derived once per run (not supplied by L3). They let a
48
+ * per-node rule consult whole-file context — the set of static `id`s in the file,
49
+ * the set of ids a `<label for>` points at, and a node's ancestor chain — without
50
+ * re-walking the tree. {@link runLiquidRules} populates them before running rules.
51
+ */
52
+ readonly idsInFile?: ReadonlySet<string>;
53
+ readonly labelTargets?: ReadonlySet<string>;
54
+ readonly ancestorsOf?: (node: HtmlElementNode) => readonly HtmlElementNode[];
55
+ }
56
+
57
+ /**
58
+ * The WCAG SC bridge: each Liquid rule id → its success criteria. The analog of
59
+ * `wcag-tags.ts` for axe — our rule ids are our own, so the mapping is direct.
60
+ * `enrichAll` keys off these SC strings, exactly as the other producers' findings do.
61
+ */
62
+ const RULE_WCAG: Readonly<Record<string, readonly string[]>> = {
63
+ "liquid/img-no-alt": ["1.1.1"],
64
+ "liquid/html-no-lang": ["3.1.1"],
65
+ "liquid/iframe-no-title": ["4.1.2"],
66
+ "liquid/control-no-name": ["4.1.2"],
67
+ // R1 — form-control labeling (#59)
68
+ "liquid/input-no-label": ["1.3.1", "4.1.2"],
69
+ "liquid/label-for-dangling": ["1.3.1"],
70
+ // R2 — document structure (#60)
71
+ "liquid/heading-order": ["1.3.1"],
72
+ "liquid/empty-heading": ["1.3.1", "2.4.6"],
73
+ "liquid/list-structure": ["1.3.1"],
74
+ // R3 — media naming (#61)
75
+ "liquid/svg-no-name": ["1.1.1"],
76
+ "liquid/media-no-captions": ["1.2.2", "1.2.3"],
77
+ "liquid/area-no-alt": ["1.1.1"],
78
+ // R4 — page / meta (#62)
79
+ "liquid/viewport-no-scale": ["1.4.4"],
80
+ "liquid/duplicate-id": ["4.1.1"],
81
+ "liquid/positive-tabindex": ["2.4.3"],
82
+ };
83
+
84
+ /** WCAG SCs for a Liquid rule id (empty if unknown — never throws). */
85
+ export function wcagForLiquidRule(ruleId: string): readonly string[] {
86
+ return RULE_WCAG[ruleId] ?? [];
87
+ }
88
+
89
+ /**
90
+ * Runtime/computed rule classes deliberately NOT implemented as static Liquid rules
91
+ * — they require a rendered DOM (computed roles, contrast, interactivity, layout)
92
+ * and are owned by the `check-url` (axe) path. Documented so the boundary is explicit
93
+ * and a future contributor doesn't try to fake them statically.
94
+ */
95
+ export const RUNTIME_EXCLUSIONS: readonly string[] = [
96
+ "color-contrast", // 1.4.3 — needs computed colors
97
+ "nested-interactive", // 4.1.2 — needs computed interactivity
98
+ "aria-required-children", // 1.3.1 — needs the full computed subtree
99
+ "aria-required-parent", // 1.3.1
100
+ "scrollable-region-focusable", // 2.1.1 — needs layout/overflow
101
+ "target-size", // 2.5.8 — needs computed box size
102
+ ];
103
+
104
+ /** Map a byte offset into `source` to a 1-based line number. */
105
+ function lineAt(source: string, offset: number): number {
106
+ let line = 1;
107
+ const stop = Math.min(offset, source.length);
108
+ for (let i = 0; i < stop; i++) {
109
+ if (source.charCodeAt(i) === 10 /* \n */) line++;
110
+ }
111
+ return line;
112
+ }
113
+
114
+ function makeFinding(
115
+ ruleId: string,
116
+ message: string,
117
+ node: HtmlElementNode,
118
+ ctx: LiquidRuleContext,
119
+ ): Finding {
120
+ return {
121
+ file: ctx.file,
122
+ line: lineAt(ctx.source, spanOf(node).start),
123
+ ruleId,
124
+ message,
125
+ wcag: RULE_WCAG[ruleId] ?? [],
126
+ enforcement: ctx.enforcement,
127
+ provenance: "liquid",
128
+ };
129
+ }
130
+
131
+ /** Does any of these naming attributes exist on the element (present in any form)? */
132
+ function hasNameAttr(node: HtmlElementNode): boolean {
133
+ return (
134
+ attr(node, "aria-label").kind !== "absent" ||
135
+ attr(node, "aria-labelledby").kind !== "absent" ||
136
+ attr(node, "title").kind !== "absent"
137
+ );
138
+ }
139
+
140
+ /** True if the element is an `<svg>` (which the parser models as an `HtmlRawNode`). */
141
+ function isSvg(node: HtmlElementNode): boolean {
142
+ return elementName(node)?.toLowerCase() === "svg";
143
+ }
144
+
145
+ /**
146
+ * Is this element removed from the accessibility tree via its own `aria-hidden`?
147
+ * A static `aria-hidden="true"` is unambiguously hidden. A *dynamic*
148
+ * `aria-hidden="{{ x }}"` is ambiguous (it may resolve to `true`), so we treat it
149
+ * conservatively as possibly-hidden — better to under-report than risk a false
150
+ * positive on a control the author may intend to hide. Any other value (`false`,
151
+ * absent, empty) is not hidden.
152
+ */
153
+ function isSelfAriaHidden(node: HtmlElementNode): boolean {
154
+ const a = attr(node, "aria-hidden");
155
+ if (a.kind === "dynamic") return true; // could resolve to "true" → conservative skip.
156
+ return a.kind === "static" && a.text.trim().toLowerCase() === "true";
157
+ }
158
+
159
+ /**
160
+ * Is this element out of the accessibility tree — either it is itself
161
+ * `aria-hidden="true"`, or any ancestor is? A missing accessible name on a node
162
+ * hidden from assistive tech is not a defect, so the naming rules skip it (#64).
163
+ */
164
+ function inAriaHiddenSubtree(node: HtmlElementNode, ctx: LiquidRuleContext): boolean {
165
+ if (isSelfAriaHidden(node)) return true;
166
+ return (ctx.ancestorsOf?.(node) ?? []).some((a) => isSelfAriaHidden(a));
167
+ }
168
+
169
+ /** Is this `<svg>` explicitly hidden from assistive tech (`aria-hidden="true"`)? */
170
+ function svgIsHidden(node: HtmlElementNode): boolean {
171
+ return /\baria-hidden\s*=\s*["']?\s*true\b/i.test(rawSourceOf(node));
172
+ }
173
+
174
+ /**
175
+ * Does this `<svg>` carry an accessible-name source? An `<svg>` parses as an opaque
176
+ * `HtmlRawNode` — its inner `<title>` is in raw markup, never child nodes — so the
177
+ * probe scans the raw source: a `<title>` element (SVG-AAM name-from-content), or an
178
+ * `aria-label`/`aria-labelledby`/`title` attribute, or `role="img"`. Conservative by
179
+ * design: any of these makes the SVG named, so an ancestor control is silent (#55).
180
+ */
181
+ function svgHasName(node: HtmlElementNode): boolean {
182
+ const raw = rawSourceOf(node);
183
+ return (
184
+ /<title[\s>]/i.test(raw) ||
185
+ /\baria-label(ledby)?\s*=/i.test(raw) ||
186
+ /\btitle\s*=/i.test(raw) ||
187
+ /\brole\s*=\s*["']?\s*img\b/i.test(raw)
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Conservative accessible-name probe for an interactive control: true if any name
193
+ * source exists *anywhere* in the subtree — static text, dynamic `{{ }}` text, a
194
+ * naming attribute on the control or a descendant, or a descendant `<img>` whose
195
+ * `alt` is not absent. Deliberately over-accepts (under-reports) to protect
196
+ * precision: the rule fires only when the control is unambiguously nameless.
197
+ */
198
+ function subtreeHasName(node: HtmlElementNode): boolean {
199
+ let named = false;
200
+ const visit = (n: LiquidHtmlNode): void => {
201
+ if (named) return;
202
+ if (n.type === NodeTypes.TextNode) {
203
+ const value = (n as { value?: string }).value;
204
+ if (typeof value === "string" && value.trim() !== "") named = true;
205
+ return;
206
+ }
207
+ if (n.type === NodeTypes.LiquidVariableOutput) {
208
+ named = true; // a render-time {{ }} text node is a (present) name source
209
+ return;
210
+ }
211
+ if (isHtmlElement(n)) {
212
+ if (hasNameAttr(n)) {
213
+ named = true;
214
+ return;
215
+ }
216
+ if (elementName(n) === "img" && attr(n, "alt").kind !== "absent") {
217
+ named = true;
218
+ return;
219
+ }
220
+ // A descendant `<svg>` with a name source (a raw `<title>`, `aria-label`,
221
+ // `role="img"`+label) gives the control a name via the SVG-AAM name-from-
222
+ // content path — the icon-button pattern. Its `<title>` is raw, not a child
223
+ // node, so probe the raw source here rather than recursing (#55).
224
+ if (isSvg(n) && svgHasName(n)) {
225
+ named = true;
226
+ return;
227
+ }
228
+ }
229
+ const kids = "children" in n && Array.isArray((n as { children?: unknown }).children)
230
+ ? ((n as unknown as { children: LiquidHtmlNode[] }).children)
231
+ : [];
232
+ for (const k of kids) visit(k);
233
+ };
234
+ visit(node);
235
+ return named;
236
+ }
237
+
238
+ type LiquidRule = (node: HtmlElementNode, ctx: LiquidRuleContext) => Finding | null;
239
+
240
+ /** `<img>` with no `alt` attribute at all (a dynamic or empty alt is present → silent). */
241
+ const imgNoAlt: LiquidRule = (node, ctx) => {
242
+ if (elementName(node) !== "img") return null;
243
+ if (attr(node, "alt").kind !== "absent") return null;
244
+ return makeFinding(
245
+ "liquid/img-no-alt",
246
+ "Image has no `alt` attribute — screen readers announce nothing for it. Add `alt` text, or `alt=\"\"` if decorative.",
247
+ node,
248
+ ctx,
249
+ );
250
+ };
251
+
252
+ /** `<html>` with no `lang` attribute. */
253
+ const htmlNoLang: LiquidRule = (node, ctx) => {
254
+ if (elementName(node) !== "html") return null;
255
+ if (attr(node, "lang").kind !== "absent") return null;
256
+ return makeFinding(
257
+ "liquid/html-no-lang",
258
+ "`<html>` has no `lang` attribute — assistive tech can't determine the page language.",
259
+ node,
260
+ ctx,
261
+ );
262
+ };
263
+
264
+ /** `<iframe>` with no `title` attribute. */
265
+ const iframeNoTitle: LiquidRule = (node, ctx) => {
266
+ if (elementName(node) !== "iframe") return null;
267
+ if (attr(node, "title").kind !== "absent") return null;
268
+ return makeFinding(
269
+ "liquid/iframe-no-title",
270
+ "`<iframe>` has no `title` attribute — its purpose is not announced to screen readers.",
271
+ node,
272
+ ctx,
273
+ );
274
+ };
275
+
276
+ /** `<button>`, or `<a href>`, with no accessible name source anywhere in its subtree. */
277
+ const controlNoName: LiquidRule = (node, ctx) => {
278
+ const name = elementName(node);
279
+ const isButton = name === "button";
280
+ const isLink = name === "a" && attr(node, "href").kind !== "absent";
281
+ if (!isButton && !isLink) return null;
282
+ // A control hidden from assistive tech (itself or via an ancestor `aria-hidden`)
283
+ // is intentionally out of the a11y tree — a missing name is not a defect (#64).
284
+ if (inAriaHiddenSubtree(node, ctx)) return null;
285
+ if (subtreeHasName(node)) return null;
286
+ const label = isButton ? "Button" : "Link";
287
+ return makeFinding(
288
+ "liquid/control-no-name",
289
+ `${label} has no accessible name — no text, \`aria-label\`, or labelled child. Screen readers announce it without a purpose.`,
290
+ node,
291
+ ctx,
292
+ );
293
+ };
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // R1 — form-control labeling (#59)
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /** `<input>` types that are not user-facing text fields and never need a label. */
300
+ const UNLABELED_INPUT_TYPES: ReadonlySet<string> = new Set([
301
+ "hidden",
302
+ "submit",
303
+ "button",
304
+ "image",
305
+ "reset",
306
+ ]);
307
+
308
+ /** Does this control carry a self-naming attribute (`aria-label`/`aria-labelledby`/`title`)? */
309
+ function hasSelfLabel(node: HtmlElementNode): boolean {
310
+ return hasNameAttr(node);
311
+ }
312
+
313
+ /** The nearest ancestor element name chain — used to detect a wrapping `<label>`. */
314
+ function hasAncestorLabel(node: HtmlElementNode, ctx: LiquidRuleContext): boolean {
315
+ const ancestors = ctx.ancestorsOf?.(node) ?? [];
316
+ return ancestors.some((a) => elementName(a)?.toLowerCase() === "label");
317
+ }
318
+
319
+ /**
320
+ * `<input>` (text-like), `<select>`, or `<textarea>` with no accessible-name source:
321
+ * no `aria-label`/`aria-labelledby`/`title`, no `for`-associated or ancestor
322
+ * `<label>`. Conservative — a dynamic naming value is present → silent; any doubt
323
+ * about a label source → silent (under-report to protect precision).
324
+ */
325
+ const inputNoLabel: LiquidRule = (node, ctx) => {
326
+ const name = elementName(node)?.toLowerCase();
327
+ if (name !== "input" && name !== "select" && name !== "textarea") return null;
328
+ if (name === "input") {
329
+ const type = attr(node, "type");
330
+ if (type.kind === "static" && UNLABELED_INPUT_TYPES.has(type.text.trim().toLowerCase())) {
331
+ return null;
332
+ }
333
+ // A dynamic `type="{{ }}"` could resolve to an excluded type — stay silent.
334
+ if (type.kind === "dynamic") return null;
335
+ }
336
+ if (hasSelfLabel(node)) return null;
337
+ // A `for`-associated `<label>` in the same file makes it labelled.
338
+ const id = attr(node, "id");
339
+ if (id.kind === "static" && ctx.labelTargets?.has(id.text.trim())) return null;
340
+ if (id.kind === "dynamic") return null; // a dynamic id may be a label target — silent.
341
+ if (hasAncestorLabel(node, ctx)) return null;
342
+ return makeFinding(
343
+ "liquid/input-no-label",
344
+ `\`<${name}>\` has no associated label — no \`aria-label\`, no \`<label for>\`, no wrapping \`<label>\`. Screen readers announce it without a purpose.`,
345
+ node,
346
+ ctx,
347
+ );
348
+ };
349
+
350
+ /**
351
+ * `<label for="X">` whose `X` matches no element `id` in the same file. Scoped to the
352
+ * file (cross-file association is not statically decidable); a dynamic `for` is silent.
353
+ */
354
+ const labelForDangling: LiquidRule = (node, ctx) => {
355
+ if (elementName(node)?.toLowerCase() !== "label") return null;
356
+ const forAttr = attr(node, "for");
357
+ if (forAttr.kind !== "static") return null; // absent/empty/dynamic → not decidable.
358
+ const target = forAttr.text.trim();
359
+ if (target === "") return null;
360
+ if (ctx.idsInFile?.has(target)) return null;
361
+ return makeFinding(
362
+ "liquid/label-for-dangling",
363
+ `\`<label for="${target}">\` points to no element with \`id="${target}"\` in this file — the label is not associated with any control.`,
364
+ node,
365
+ ctx,
366
+ );
367
+ };
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // R2 — document structure (#60)
371
+ // ---------------------------------------------------------------------------
372
+
373
+ const HEADING_TAGS: ReadonlySet<string> = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
374
+
375
+ /** The numeric level of a heading tag (`h2` → 2), or `null` if not a heading. */
376
+ function headingLevel(node: HtmlElementNode): number | null {
377
+ const name = elementName(node)?.toLowerCase();
378
+ if (name && HEADING_TAGS.has(name)) return Number(name[1]);
379
+ return null;
380
+ }
381
+
382
+ /** A heading (`h1`–`h6`) with no text and no dynamic `{{ }}` content in its subtree. */
383
+ const emptyHeading: LiquidRule = (node, ctx) => {
384
+ if (headingLevel(node) === null) return null;
385
+ // `subtreeHasName` already means "any text or dynamic `{{ }}` content present".
386
+ if (subtreeHasName(node)) return null;
387
+ return makeFinding(
388
+ "liquid/empty-heading",
389
+ `\`<${elementName(node)}>\` is empty — a heading with no text gives screen-reader users a meaningless landmark.`,
390
+ node,
391
+ ctx,
392
+ );
393
+ };
394
+
395
+ /** Direct-child element tags that don't count as a list-structure violation. */
396
+ const LIST_CONTENT_IGNORE: ReadonlySet<string> = new Set(["li", "script", "template"]);
397
+
398
+ /**
399
+ * A `<ul>`/`<ol>` with a direct child *element* that is not `<li>` (ignoring text,
400
+ * `{% %}` wrappers, `<script>`, `<template>`). A `{% for %}`/`{% if %}` wraps its
401
+ * `<li>` in a Liquid node, which is transparent here — so loop-built lists are silent.
402
+ */
403
+ const listStructure: LiquidRule = (node, ctx) => {
404
+ const name = elementName(node)?.toLowerCase();
405
+ if (name !== "ul" && name !== "ol") return null;
406
+ const children = (node as { children?: LiquidHtmlNode[] }).children ?? [];
407
+ const offender = children.find(
408
+ (c) => isHtmlElement(c) && !LIST_CONTENT_IGNORE.has(elementName(c)?.toLowerCase() ?? ""),
409
+ );
410
+ if (!offender) return null;
411
+ return makeFinding(
412
+ "liquid/list-structure",
413
+ `\`<${name}>\` has a direct child \`<${elementName(offender as HtmlElementNode)}>\` that is not \`<li>\` — only \`<li>\` may be a direct child of a list.`,
414
+ node,
415
+ ctx,
416
+ );
417
+ };
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // R3 — media naming (#61)
421
+ // ---------------------------------------------------------------------------
422
+
423
+ /** Tags that own their own accessible name (an `<svg>` inside one is covered there). */
424
+ const NAME_OWNING_ANCESTORS: ReadonlySet<string> = new Set(["a", "button", "label", "summary"]);
425
+
426
+ /**
427
+ * A standalone informative `<svg>` with no name source: no `<title>`,
428
+ * `aria-label`/`aria-labelledby`, `role`, and not `aria-hidden="true"`. An `<svg>`
429
+ * inside an interactive control is the icon-button case `control-no-name` already
430
+ * owns, so it is skipped here to avoid double-reporting. The svg parses as an
431
+ * `HtmlRawNode`, so name sources are read from its raw markup.
432
+ */
433
+ const svgNoName: LiquidRule = (node, ctx) => {
434
+ if (!isSvg(node)) return null;
435
+ if (svgIsHidden(node)) return null; // decorative (own `aria-hidden`) → exempt.
436
+ // Also exempt when an ancestor is `aria-hidden="true"` — the svg is out of the
437
+ // a11y tree, so a missing name is not a defect (#64).
438
+ if (inAriaHiddenSubtree(node, ctx)) return null;
439
+ if (svgHasName(node)) return null;
440
+ const inControl = (ctx.ancestorsOf?.(node) ?? []).some((a) =>
441
+ NAME_OWNING_ANCESTORS.has(elementName(a)?.toLowerCase() ?? ""),
442
+ );
443
+ if (inControl) return null; // covered by `control-no-name`.
444
+ // `role=` on the svg (presentation/none/img/anything) means the author has made an
445
+ // intentional choice — conservative: any role → silent.
446
+ if (/\brole\s*=/i.test(rawSourceOf(node))) return null;
447
+ return makeFinding(
448
+ "liquid/svg-no-name",
449
+ "`<svg>` has no accessible name — no `<title>`, `aria-label`, or `role`. Add a `<title>`, or `aria-hidden=\"true\"` if decorative.",
450
+ node,
451
+ ctx,
452
+ );
453
+ };
454
+
455
+ /** Does this `<video>`/`<audio>` have any `<track>` child element? */
456
+ function hasTrackChild(node: HtmlElementNode): boolean {
457
+ const children = (node as { children?: LiquidHtmlNode[] }).children ?? [];
458
+ return children.some((c) => isHtmlElement(c) && elementName(c)?.toLowerCase() === "track");
459
+ }
460
+
461
+ /** A `<video>`/`<audio>` with no `<track>` child (captions/subtitles source). */
462
+ const mediaNoCaptions: LiquidRule = (node, ctx) => {
463
+ const name = elementName(node)?.toLowerCase();
464
+ if (name !== "video" && name !== "audio") return null;
465
+ if (hasTrackChild(node)) return null;
466
+ return makeFinding(
467
+ "liquid/media-no-captions",
468
+ `\`<${name}>\` has no \`<track>\` — deaf and hard-of-hearing users get no captions or subtitles.`,
469
+ node,
470
+ ctx,
471
+ );
472
+ };
473
+
474
+ /** An image-map `<area>` with no `alt` attribute. */
475
+ const areaNoAlt: LiquidRule = (node, ctx) => {
476
+ if (elementName(node)?.toLowerCase() !== "area") return null;
477
+ if (attr(node, "alt").kind !== "absent") return null;
478
+ return makeFinding(
479
+ "liquid/area-no-alt",
480
+ "`<area>` has no `alt` attribute — its image-map region is announced without a purpose. Add `alt` text.",
481
+ node,
482
+ ctx,
483
+ );
484
+ };
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // R4 — page / meta (#62)
488
+ // ---------------------------------------------------------------------------
489
+
490
+ /** A `<meta name="viewport">` whose `content` disables zoom. */
491
+ const viewportNoScale: LiquidRule = (node, ctx) => {
492
+ if (elementName(node)?.toLowerCase() !== "meta") return null;
493
+ const metaName = attr(node, "name");
494
+ if (metaName.kind !== "static" || metaName.text.trim().toLowerCase() !== "viewport") return null;
495
+ const content = attr(node, "content");
496
+ if (content.kind !== "static") return null; // dynamic content → not decidable → silent.
497
+ const value = content.text.toLowerCase();
498
+ const disablesZoom =
499
+ /user-scalable\s*=\s*(no|0)/.test(value) || maximumScaleUnderTwo(value);
500
+ if (!disablesZoom) return null;
501
+ return makeFinding(
502
+ "liquid/viewport-no-scale",
503
+ "`<meta name=\"viewport\">` disables zoom (`user-scalable=no`/`maximum-scale<2`) — low-vision users can't pinch-zoom the page.",
504
+ node,
505
+ ctx,
506
+ );
507
+ };
508
+
509
+ /** True if the viewport content sets `maximum-scale` to a value below 2. */
510
+ function maximumScaleUnderTwo(content: string): boolean {
511
+ const match = /maximum-scale\s*=\s*([0-9.]+)/.exec(content);
512
+ if (!match) return false;
513
+ const scale = Number(match[1]);
514
+ return Number.isFinite(scale) && scale < 2;
515
+ }
516
+
517
+ /** An element with a static `tabindex` greater than 0. */
518
+ const positiveTabindex: LiquidRule = (node, ctx) => {
519
+ const tabindex = attr(node, "tabindex");
520
+ if (tabindex.kind !== "static") return null; // dynamic/absent → silent.
521
+ const value = Number(tabindex.text.trim());
522
+ if (!Number.isFinite(value) || value <= 0) return null;
523
+ return makeFinding(
524
+ "liquid/positive-tabindex",
525
+ `\`tabindex="${tabindex.text.trim()}"\` forces a positive tab order — it breaks the natural focus sequence. Use \`0\` or \`-1\`.`,
526
+ node,
527
+ ctx,
528
+ );
529
+ };
530
+
531
+ /** The full structural-absence rule set, in evaluation order. */
532
+ const RULES: readonly LiquidRule[] = [
533
+ imgNoAlt,
534
+ htmlNoLang,
535
+ iframeNoTitle,
536
+ controlNoName,
537
+ inputNoLabel,
538
+ labelForDangling,
539
+ emptyHeading,
540
+ listStructure,
541
+ svgNoName,
542
+ mediaNoCaptions,
543
+ areaNoAlt,
544
+ viewportNoScale,
545
+ positiveTabindex,
546
+ ];
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // File-level rules (#60 heading-order, #62 duplicate-id) — they need the whole
550
+ // element sequence, not a single node, so they run once over an ordered list of
551
+ // the file's elements rather than per node.
552
+ // ---------------------------------------------------------------------------
553
+
554
+ /**
555
+ * A heading that skips a level relative to the preceding heading in document order
556
+ * (e.g. an `h2` followed by an `h4`). The first heading and any descent (`h4`→`h2`)
557
+ * or equal level are fine. A dynamic heading tag (`<h{{ n }}>`) has no static level,
558
+ * so it neither fires nor resets the tracker — handled gracefully by skipping it.
559
+ */
560
+ function headingOrderFindings(
561
+ ordered: readonly HtmlElementNode[],
562
+ ctx: LiquidRuleContext,
563
+ ): Finding[] {
564
+ const findings: Finding[] = [];
565
+ let last: number | null = null;
566
+ for (const node of ordered) {
567
+ const level = headingLevel(node);
568
+ if (level === null) continue;
569
+ if (last !== null && level > last + 1) {
570
+ findings.push(
571
+ makeFinding(
572
+ "liquid/heading-order",
573
+ `Heading level jumps from \`h${last}\` to \`h${level}\` — a skipped level breaks the document outline for screen-reader users.`,
574
+ node,
575
+ ctx,
576
+ ),
577
+ );
578
+ }
579
+ last = level;
580
+ }
581
+ return findings;
582
+ }
583
+
584
+ /**
585
+ * Two or more elements in the file sharing the same static `id`. Dynamic ids
586
+ * (`id="{{ }}"`) are not statically decidable and are skipped. One finding per
587
+ * duplicate occurrence after the first, anchored on the offending element.
588
+ */
589
+ function duplicateIdFindings(
590
+ ordered: readonly HtmlElementNode[],
591
+ ctx: LiquidRuleContext,
592
+ ): Finding[] {
593
+ const findings: Finding[] = [];
594
+ const seen = new Set<string>();
595
+ for (const node of ordered) {
596
+ const id = attr(node, "id");
597
+ if (id.kind !== "static") continue;
598
+ const value = id.text.trim();
599
+ if (value === "") continue;
600
+ if (seen.has(value)) {
601
+ findings.push(
602
+ makeFinding(
603
+ "liquid/duplicate-id",
604
+ `Duplicate \`id="${value}"\` — an id must be unique in a document; assistive-tech associations (\`for\`, \`aria-labelledby\`) resolve to the wrong element.`,
605
+ node,
606
+ ctx,
607
+ ),
608
+ );
609
+ } else {
610
+ seen.add(value);
611
+ }
612
+ }
613
+ return findings;
614
+ }
615
+
616
+ /** The static `id` value on an element, or `null` if absent/empty/dynamic. */
617
+ function staticId(node: HtmlElementNode): string | null {
618
+ const id = attr(node, "id");
619
+ if (id.kind !== "static") return null;
620
+ const value = id.text.trim();
621
+ return value === "" ? null : value;
622
+ }
623
+
624
+ /** The static `for` target on a `<label>`, or `null`. */
625
+ function labelForTarget(node: HtmlElementNode): string | null {
626
+ if (elementName(node)?.toLowerCase() !== "label") return null;
627
+ const forAttr = attr(node, "for");
628
+ if (forAttr.kind !== "static") return null;
629
+ const value = forAttr.text.trim();
630
+ return value === "" ? null : value;
631
+ }
632
+
633
+ /**
634
+ * Run every structural-absence rule over a parsed Liquid AST, returning the findings.
635
+ * The single entry point L3's producer calls per file.
636
+ *
637
+ * Two passes: first an ordered walk builds the file-scoped indexes (every element in
638
+ * document order, the set of static ids, the `for`-target ids, and an ancestor map),
639
+ * which file-level rules and label-aware per-node rules consult; then per-node rules
640
+ * run over each element with that context, and the file-level rules run once.
641
+ */
642
+ export function runLiquidRules(ast: DocumentNode, baseCtx: LiquidRuleContext): Finding[] {
643
+ const ordered: HtmlElementNode[] = [];
644
+ const idsInFile = new Set<string>();
645
+ const labelTargets = new Set<string>();
646
+ const parentOf = new Map<HtmlElementNode, HtmlElementNode | null>();
647
+
648
+ // One pre-order recursion: collect elements in document order, index ids and
649
+ // label targets, and record each element's parent (for ancestor lookup).
650
+ const visit = (n: LiquidHtmlNode, parentEl: HtmlElementNode | null): void => {
651
+ let nextParent = parentEl;
652
+ if (isHtmlElement(n)) {
653
+ ordered.push(n);
654
+ parentOf.set(n, parentEl);
655
+ const id = staticId(n);
656
+ if (id !== null) idsInFile.add(id);
657
+ const forTarget = labelForTarget(n);
658
+ if (forTarget !== null) labelTargets.add(forTarget);
659
+ nextParent = n;
660
+ }
661
+ const kids =
662
+ "children" in n && Array.isArray((n as { children?: unknown }).children)
663
+ ? (n as unknown as { children: LiquidHtmlNode[] }).children
664
+ : [];
665
+ for (const k of kids) visit(k, nextParent);
666
+ };
667
+ visit(ast, null);
668
+
669
+ const ancestorsOf = (node: HtmlElementNode): HtmlElementNode[] => {
670
+ const chain: HtmlElementNode[] = [];
671
+ let p = parentOf.get(node) ?? null;
672
+ while (p) {
673
+ chain.push(p);
674
+ p = parentOf.get(p) ?? null;
675
+ }
676
+ return chain;
677
+ };
678
+
679
+ const ctx: LiquidRuleContext = { ...baseCtx, idsInFile, labelTargets, ancestorsOf };
680
+
681
+ const findings: Finding[] = [];
682
+ for (const node of ordered) {
683
+ for (const rule of RULES) {
684
+ const finding = rule(node, ctx);
685
+ if (finding) findings.push(finding);
686
+ }
687
+ }
688
+ findings.push(...headingOrderFindings(ordered, ctx));
689
+ findings.push(...duplicateIdFindings(ordered, ctx));
690
+ return findings;
691
+ }