@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,249 @@
1
+ /**
2
+ * Unity label resolution — the core precision rule for the Unity producer (#70, child
3
+ * of #66, ADR 0004). The Unity analog of the Liquid present/dynamic/absent attribute
4
+ * seam (`.patterns/liquid-html-parser/attributes.md`, `src/enforce.ts`).
5
+ *
6
+ * A uGUI interactive widget (Button / Toggle) carries its accessible label NOT on
7
+ * itself but on a **child** `TextMeshProUGUI` / `Text`'s `m_text` field. That same
8
+ * child very often also carries a `LocalizeStringEvent` (Unity Localization package)
9
+ * whose `m_UpdateString` calls `set_text` on the sibling TMP at runtime, overwriting
10
+ * `m_text` from a localization table (`m_StringReference.m_TableReference` /
11
+ * `m_TableEntryReference`). So the static `m_text` a file-reader sees is, for a
12
+ * localized widget, a placeholder (often a single char like `X`) — the visible label
13
+ * is injected at runtime and is **not statically knowable**.
14
+ *
15
+ * The label therefore has **three** static states, not a boolean:
16
+ *
17
+ * - `Static(text)` — `m_text` is the real label: a text-bearing child exists and no
18
+ * enabled LocalizeStringEvent with a real table reference overrides it.
19
+ * - `Dynamic` — an ENABLED LocalizeStringEvent with a non-empty table reference
20
+ * is present on the text child: the label is runtime-injected. Treat it OPAQUE —
21
+ * do NOT flag as missing (the no-false-positive lock, story 3). This is the
22
+ * precision crux: a naive `m_text`-only read false-positives the *majority* of
23
+ * localized buttons in a real Unity project, the failure mode that gets an a11y
24
+ * tool uninstalled (the precision invariant in `CLAUDE.md`).
25
+ * - `Absent` — no text-bearing child at all: the genuine missing-label finding
26
+ * (story 4).
27
+ *
28
+ * The precision invariant the whole resolver lives by holds here: resolve to the
29
+ * correct state or stay opaque (`Dynamic`), never produce a false `Absent` on a
30
+ * localized widget.
31
+ *
32
+ * Why this module re-reads the raw source for the LocalizeStringEvent fields: the L1
33
+ * AST (`unity-ast.ts`) captures the field surface the *graph* needs (`m_text`,
34
+ * `m_Script` guid, `m_Children`) but not the LocalizeStringEvent's `m_Enabled` /
35
+ * nested `m_StringReference` sub-block. Rather than widen the shared AST (this is a
36
+ * self-contained rule, #70; integration is a later child), the dynamic-detection
37
+ * fields are read here, keyed by the component's `&fileID`, from the same source the
38
+ * graph was parsed from.
39
+ */
40
+
41
+ import {
42
+ childGameObjects,
43
+ resolveComponentIdentity,
44
+ type FileId,
45
+ type UnityComponent,
46
+ type UnityGameObject,
47
+ type UnityGraph,
48
+ } from "./unity-ast";
49
+ import type { UnityWidgetKind } from "./unity-guid-registry";
50
+
51
+ /** The serialized class name of the runtime-localization component whose presence +
52
+ * enabled + table reference makes a label dynamic. */
53
+ const LOCALIZE_STRING_EVENT_TYPE = "LocalizeStringEvent";
54
+
55
+ /** The stable built-in guid of `LocalizeStringEvent` (Unity Localization package),
56
+ * grounded against the #70 corpus anchor `UnityTechnologies/open-project-1` @ 608eac98
57
+ * (`Tab_Item.prefab`, `GenericButton.prefab`, `Button.prefab`). Identity is keyed on
58
+ * either the type name or this guid — tolerant of which the serializer emitted. */
59
+ export const LOCALIZE_STRING_EVENT_GUID = "56eb0353ae6e5124bb35b17aff880f16";
60
+
61
+ /** The widget kinds that bear an accessible-name `m_text` — uGUI legacy `Text` and
62
+ * `TextMeshProUGUI`. A text widget serializes as a generic `MonoBehaviour` whose
63
+ * IDENTITY is its `m_Script` guid, so we resolve it via the built-in registry (which
64
+ * maps both to `host: "text"`), never by the serialized type name (which is just
65
+ * "MonoBehaviour"). This keeps the seam grounded on the same guid table the rest of
66
+ * the resolver uses. */
67
+ const TEXT_BEARING_WIDGETS = new Set<UnityWidgetKind>([
68
+ "TextMeshProUGUI",
69
+ "Text",
70
+ ]);
71
+
72
+ /**
73
+ * The 3-state label resolution result — a discriminated union, never a boolean. This
74
+ * mirrors the Liquid attribute seam: `Static` ≙ present literal, `Dynamic` ≙
75
+ * runtime-injected (opaque, not flagged), `Absent` ≙ genuinely missing (flagged).
76
+ */
77
+ export type UnityLabel =
78
+ | { readonly kind: "static"; readonly text: string }
79
+ | { readonly kind: "dynamic" }
80
+ | { readonly kind: "absent" };
81
+
82
+ /** Constructors — keep call sites total and the union the single shape callers match. */
83
+ export const UnityLabel = {
84
+ static: (text: string): UnityLabel => ({ kind: "static", text }),
85
+ dynamic: (): UnityLabel => ({ kind: "dynamic" }),
86
+ absent: (): UnityLabel => ({ kind: "absent" }),
87
+ } as const;
88
+
89
+ /**
90
+ * Resolve the accessible label of an interactive widget GameObject to its 3-state value.
91
+ *
92
+ * Walks `m_Children` (one level, via the transform indirection `childGameObjects`
93
+ * resolves) looking for a text-bearing child component (`TextMeshProUGUI` / `Text`):
94
+ *
95
+ * 1. No text-bearing child found anywhere in the children → `Absent`.
96
+ * 2. A text-bearing child whose GameObject also carries an ENABLED
97
+ * LocalizeStringEvent with a non-empty table reference → `Dynamic` (opaque).
98
+ * 3. Otherwise → `Static(m_text)` (the static literal is the label; an empty/missing
99
+ * `m_text` with no localization is `Static("")`, still a resolved label state,
100
+ * not absent — absence is *no text component at all*).
101
+ *
102
+ * `source` is the same Force-Text source the `graph` was parsed from; it is read only
103
+ * for the LocalizeStringEvent fields the L1 AST does not capture (`m_Enabled`, the
104
+ * nested `m_StringReference`), keyed by each component's `&fileID`.
105
+ *
106
+ * @returns the 3-state `UnityLabel`. Never throws — a malformed sub-block degrades to
107
+ * the conservative state (a non-readable LocalizeStringEvent is treated as not
108
+ * dynamic, so the visible `m_text` governs).
109
+ */
110
+ export function resolveUnityLabel(
111
+ graph: UnityGraph,
112
+ widget: UnityGameObject,
113
+ source: string,
114
+ ): UnityLabel {
115
+ const localizeEvents = readLocalizeStringEvents(source);
116
+
117
+ // First text-bearing child wins (a uGUI widget has a single label child by
118
+ // convention; if several exist, the first in hierarchy order is the label).
119
+ for (const child of childGameObjects(graph, widget)) {
120
+ const textComponent = child.components.find((c) => isTextBearing(graph, c));
121
+ if (!textComponent) continue;
122
+
123
+ // Dynamic check: does THIS text child's GameObject carry an enabled
124
+ // LocalizeStringEvent with a real table reference? If so the label is
125
+ // runtime-injected → opaque, do not flag.
126
+ const hasDynamicLabel = child.components.some((component) => {
127
+ if (!isLocalizeStringEvent(component)) return false;
128
+ const fields = localizeEvents.get(component.fileId);
129
+ return fields != null && fields.enabled && fields.hasTableReference;
130
+ });
131
+ if (hasDynamicLabel) return UnityLabel.dynamic();
132
+
133
+ return UnityLabel.static(textComponent.text ?? "");
134
+ }
135
+
136
+ return UnityLabel.absent();
137
+ }
138
+
139
+ /** A component carries an accessible-name `m_text` (uGUI `Text` / TMP). Resolved via
140
+ * the built-in-widget guid registry — a text widget serializes as a generic
141
+ * `MonoBehaviour`, so its identity is its `m_Script` guid, not its type name. */
142
+ function isTextBearing(graph: UnityGraph, component: UnityComponent): boolean {
143
+ const identity = resolveComponentIdentity(graph, component);
144
+ return identity.kind === "widget" && TEXT_BEARING_WIDGETS.has(identity.widget);
145
+ }
146
+
147
+ /** A component is a `LocalizeStringEvent` — keyed on the built-in guid (robust to
148
+ * Unity's generic `MonoBehaviour` type name) or, defensively, an explicit type name. */
149
+ function isLocalizeStringEvent(component: UnityComponent): boolean {
150
+ return (
151
+ component.scriptGuid === LOCALIZE_STRING_EVENT_GUID ||
152
+ component.typeName === LOCALIZE_STRING_EVENT_TYPE
153
+ );
154
+ }
155
+
156
+ /** The dynamic-detection fields of one LocalizeStringEvent, not captured by the L1 AST. */
157
+ interface LocalizeStringEventFields {
158
+ /** `m_Enabled: 1` — a disabled event injects nothing at runtime (the base prefab's
159
+ * shape: disabled + empty reference → the static `m_text` governs). */
160
+ readonly enabled: boolean;
161
+ /** A non-empty table reference — either a non-empty `m_TableCollectionName` (a name
162
+ * or a `GUID:<hex>` form) OR a non-zero `m_KeyId` OR a non-empty `m_Key`. Any one is
163
+ * a real runtime label source. The base prefab's empty-on-all-three reference is NOT
164
+ * a real reference, so disabled-or-empty ⇒ not dynamic. */
165
+ readonly hasTableReference: boolean;
166
+ }
167
+
168
+ const HEADER_RE = /^--- !u!(\d+) &(\d+)/;
169
+ const ENABLED_RE = /^\s*m_Enabled:\s*(\d+)/;
170
+ const TABLE_COLLECTION_RE = /^\s*m_TableCollectionName:\s*(.*)$/;
171
+ const KEY_ID_RE = /^\s*m_KeyId:\s*(\d+)/;
172
+ const KEY_RE = /^\s*m_Key:\s*(.*)$/;
173
+ const GUID_LINE_RE = /guid:\s*([0-9a-fA-F]{32})/;
174
+
175
+ /**
176
+ * Read every LocalizeStringEvent document's dynamic-detection fields from the raw
177
+ * Force-Text source, indexed by the component's `&fileID`. Line-oriented over the same
178
+ * `--- !u!N &id` document blocks the L1 AST splits on; we only scan blocks whose
179
+ * `m_Script` guid is the LocalizeStringEvent guid, and read the three reference fields
180
+ * plus `m_Enabled`. Tolerant: a malformed block simply contributes no entry (treated
181
+ * as not-dynamic by the caller — the conservative direction).
182
+ */
183
+ function readLocalizeStringEvents(source: string): Map<FileId, LocalizeStringEventFields> {
184
+ const out = new Map<FileId, LocalizeStringEventFields>();
185
+ const lines = source.split("\n");
186
+
187
+ let currentFileId: FileId | null = null;
188
+ let block: string[] = [];
189
+
190
+ const flush = () => {
191
+ if (currentFileId == null) return;
192
+ const isLocalize = block.some((l) => {
193
+ const m = /^\s*m_Script:.*$/.test(l) ? GUID_LINE_RE.exec(l) : null;
194
+ return m != null && m[1]!.toLowerCase() === LOCALIZE_STRING_EVENT_GUID;
195
+ });
196
+ if (isLocalize) {
197
+ out.set(currentFileId, parseFields(block));
198
+ }
199
+ };
200
+
201
+ for (const line of lines) {
202
+ const header = HEADER_RE.exec(line);
203
+ if (header) {
204
+ flush();
205
+ currentFileId = header[2]!;
206
+ block = [];
207
+ } else if (currentFileId != null) {
208
+ block.push(line);
209
+ }
210
+ }
211
+ flush();
212
+
213
+ return out;
214
+ }
215
+
216
+ /** Extract `m_Enabled` + whether any table reference is non-empty from one block. */
217
+ function parseFields(lines: readonly string[]): LocalizeStringEventFields {
218
+ let enabled = false;
219
+ let hasTableReference = false;
220
+
221
+ for (const line of lines) {
222
+ const enabledMatch = ENABLED_RE.exec(line);
223
+ if (enabledMatch) {
224
+ enabled = enabledMatch[1] === "1";
225
+ continue;
226
+ }
227
+ const collectionMatch = TABLE_COLLECTION_RE.exec(line);
228
+ if (collectionMatch && stripComment(collectionMatch[1] ?? "") !== "") {
229
+ hasTableReference = true;
230
+ continue;
231
+ }
232
+ const keyIdMatch = KEY_ID_RE.exec(line);
233
+ if (keyIdMatch && keyIdMatch[1] !== "0") {
234
+ hasTableReference = true;
235
+ continue;
236
+ }
237
+ const keyMatch = KEY_RE.exec(line);
238
+ if (keyMatch && stripComment(keyMatch[1] ?? "") !== "") {
239
+ hasTableReference = true;
240
+ }
241
+ }
242
+
243
+ return { enabled, hasTableReference };
244
+ }
245
+
246
+ /** Strip a trailing YAML comment + surrounding whitespace from a scalar value. */
247
+ function stripComment(raw: string): string {
248
+ return raw.replace(/\s+#.*$/, "").trim();
249
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The Unity "color-only interactive state" rule — a per-widget structural-absence
3
+ * check on the Unity producer (issue #73, child of #66), the Unity analog of the
4
+ * Liquid structural rules in `liquid-rules.ts`.
5
+ *
6
+ * What it flags: a uGUI **Selectable** (Button, Toggle, Slider — any component that
7
+ * carries the Selectable serialization, i.e. an `m_Transition` field) whose
8
+ * `m_Transition` is **`1` (ColorTint)**. ColorTint conveys every interactive state —
9
+ * normal / highlighted / pressed / selected / **disabled** — by COLOR ALONE, with no
10
+ * non-color cue. That is a WCAG 1.4.1 (Use of Color) failure: a state distinguishable
11
+ * only by color is invisible to color-blind and low-vision users and is never surfaced
12
+ * to a screen reader.
13
+ *
14
+ * The `m_Transition` enum (Unity's `Selectable.Transition`):
15
+ * - `0` None — no visual state cue at all (a *different* defect, out of scope here)
16
+ * - `1` ColorTint — color-only → **finding**
17
+ * - `2` SpriteSwap — the sprite (shape) changes per state → non-color cue → silent
18
+ * - `3` Animation — an animator drives per-state visuals → non-color cue → silent
19
+ *
20
+ * Why this is the right cheap signal — real-corpus grounding (#66): in
21
+ * `UnityTechnologies/open-project-1` @ 608eac9, **41 of 46 Selectables use
22
+ * `m_Transition: 1`** (~89% prevalence). The transition mode is a single serialized
23
+ * field on the resolved Selectable, so the check is cheap and high-frequency. The
24
+ * real-world frequency is carried, like every other producer's findings, by the
25
+ * corpus enrichment keyed off the WCAG SC (1.4.1) — not by a raw number on the
26
+ * `Finding` itself.
27
+ *
28
+ * Precision invariant (the resolver's law, honored here): we fire ONLY when the
29
+ * Selectable serialization is present AND `m_Transition` is unambiguously `1`. A
30
+ * component with no `m_Transition` is not a Selectable (`transition === null`) and is
31
+ * never touched; an opaque (binary / unparseable) asset contributes no finding rather
32
+ * than a guess. We never flag SpriteSwap/Animation/None — each carries (or deliberately
33
+ * omits) a non-color cue, so flagging it would be the false positive that gets the tool
34
+ * uninstalled.
35
+ *
36
+ * Out of scope (a deeper rule, #66 notes): the contrast-ratio computation of the actual
37
+ * tint colors. This slice flags the *transition mode*, the cheap structural signal.
38
+ *
39
+ * Consumes the `collect-unity` producer's output (the parsed `UnityGraph` per asset),
40
+ * never a re-parse — {@link scanColorOnlyState} walks a {@link UnityScanResult} directly.
41
+ */
42
+
43
+ import type { Finding } from "./core";
44
+ import type { UnityAsset, UnityScanResult } from "./collect-unity";
45
+ import type { UnityComponent } from "./unity-ast";
46
+
47
+ /** This rule's stable id (the `unity/` namespace mirrors `liquid/` rule ids). */
48
+ export const COLOR_ONLY_STATE_RULE_ID = "unity/color-only-state" as const;
49
+
50
+ /** The `m_Transition: 1` value — ColorTint, the color-only state transition. */
51
+ const COLOR_TINT = 1;
52
+
53
+ /** The WCAG Success Criteria this rule maps to: 1.4.1 Use of Color. The corpus
54
+ * enrichment keys off this SC for the real-world frequency signal, exactly as the
55
+ * other producers' findings do. */
56
+ const RULE_WCAG: readonly string[] = ["1.4.1"];
57
+
58
+ /** WCAG SCs for the color-only-state rule (the wcag bridge, analog of
59
+ * `wcagForLiquidRule`). Stable shape: a function so a caller need not import the array. */
60
+ export function wcagForColorOnlyState(): readonly string[] {
61
+ return RULE_WCAG;
62
+ }
63
+
64
+ /** What a caller supplies so a finding can be anchored to its asset. The producer's
65
+ * {@link UnityAsset} already carries both fields, so {@link unityColorOnlyStateFindings}
66
+ * accepts it directly. */
67
+ export interface ColorOnlyStateContext {
68
+ /** The `.prefab` / `.unity` file the finding is anchored in. */
69
+ readonly file: string;
70
+ /** The asset's parse outcome from the producer (graph, or opaque). */
71
+ readonly parse: UnityAsset["parse"];
72
+ }
73
+
74
+ /** A Selectable is any component that serializes `m_Transition` (`transition !== null`). */
75
+ function isSelectable(component: UnityComponent): component is UnityComponent & { transition: number } {
76
+ return component.transition !== null;
77
+ }
78
+
79
+ function makeFinding(file: string): Finding {
80
+ return {
81
+ file,
82
+ // A serialized asset has no meaningful source line for a logical component; the
83
+ // anchor is the file (the asset is the unit), mirroring the axe path's line-0 convention.
84
+ line: 0,
85
+ ruleId: COLOR_ONLY_STATE_RULE_ID,
86
+ message:
87
+ "uGUI Selectable uses ColorTint transition (`m_Transition: 1`) — interactive state " +
88
+ "(highlighted / pressed / selected / disabled) is conveyed by color alone. " +
89
+ "Color-blind and low-vision users, and screen-reader users, get no state cue. " +
90
+ "Use SpriteSwap or an Animation transition to add a non-color cue.",
91
+ wcag: RULE_WCAG,
92
+ // The static floor's default enforcement (the `decideEnforcement` no-contract
93
+ // default is `block`); a Unity producer asset has no per-file config seam yet, so it
94
+ // reports at the floor like the other producers' findings.
95
+ enforcement: "block",
96
+ provenance: "unity",
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Findings for one Unity asset: one finding per ColorTint (`m_Transition: 1`)
102
+ * Selectable in the asset's parsed graph. An opaque asset (binary / unparseable)
103
+ * yields `[]` — opaque is reported by the producer, not guessed at here.
104
+ */
105
+ export function unityColorOnlyStateFindings(ctx: ColorOnlyStateContext): Finding[] {
106
+ if (ctx.parse.kind !== "graph") return [];
107
+ const findings: Finding[] = [];
108
+ for (const component of ctx.parse.graph.components.values()) {
109
+ if (isSelectable(component) && component.transition === COLOR_TINT) {
110
+ findings.push(makeFinding(ctx.file));
111
+ }
112
+ }
113
+ return findings;
114
+ }
115
+
116
+ /**
117
+ * Run the color-only-state rule over a whole {@link UnityScanResult} — the entry point
118
+ * a CLI/dispatch would call. Consumes the producer's per-asset parse output directly
119
+ * (no re-parse), and is forgiving: an opaque asset simply contributes nothing.
120
+ */
121
+ export function scanColorOnlyState(scan: UnityScanResult): Finding[] {
122
+ const findings: Finding[] = [];
123
+ for (const asset of scan.assets) {
124
+ findings.push(...unityColorOnlyStateFindings(asset));
125
+ }
126
+ return findings;
127
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The Unity missing-accessible-label rule — the per-widget structural-absence check
3
+ * that the 3-state label resolver (`unity-label-resolve.ts`, #70) was built to feed
4
+ * (#88, child of epic #87). The Unity analog of the Liquid `*-no-name` rules and the
5
+ * SwiftUI unlabeled-control rule, sitting one altitude up on the resolver's `Absent`
6
+ * state.
7
+ *
8
+ * What it flags: an interactive uGUI widget — a Button / Toggle, or any Selectable —
9
+ * whose accessible label resolves to `Absent` (`resolveUnityLabel(...).kind ===
10
+ * "absent"`), i.e. it carries no text-bearing child at all. A control with no
11
+ * accessible name exposes no name/role to assistive tech: WCAG 1.1.1 (Non-text
12
+ * Content, for the missing name) / 4.1.2 (Name, Role, Value).
13
+ *
14
+ * Precision invariant (ADR 0004, the resolver's law, honored here strictly): we emit
15
+ * ONLY on `Absent`. We emit NOTHING on:
16
+ * - `static` — a real `m_text` label is present (even an empty literal is a resolved
17
+ * label state, not an absence).
18
+ * - `dynamic` — a runtime-localized label (an enabled LocalizeStringEvent with a real
19
+ * table reference): the visible label is injected at runtime and is NOT statically
20
+ * knowable, so it is opaque, never flagged. Emitting a false `Absent` here is the
21
+ * wrong-host-class failure that gets an a11y tool uninstalled (`CLAUDE.md`).
22
+ * - an opaque / binary asset — no graph to walk, so no finding (opaque is reported by
23
+ * the producer `scanUnity`, never guessed at here).
24
+ *
25
+ * "Interactive widget" = a GameObject carrying a component whose built-in identity is a
26
+ * control host (`host === "button"`: Button / Toggle) OR a Selectable (any component
27
+ * serializing `m_Transition`). Both are the controls that owe an accessible name. A
28
+ * widget resolved to an opaque/custom component is never treated as interactive (the
29
+ * precision invariant: correct widget or opaque, never wrong-host).
30
+ *
31
+ * Consumes the `collect-unity` producer's output (the parsed `UnityGraph` per asset),
32
+ * never a re-parse — {@link scanMissingLabel} walks a {@link UnityScanResult} directly.
33
+ */
34
+
35
+ import type { UnityAsset, UnityScanResult } from "./collect-unity";
36
+ import type { Finding } from "./core";
37
+ import {
38
+ type UnityComponent,
39
+ type UnityGameObject,
40
+ type UnityGraph,
41
+ resolveComponentIdentity,
42
+ } from "./unity-ast";
43
+ import { resolveUnityLabel } from "./unity-label-resolve";
44
+
45
+ /** This rule's stable id (the `unity/` namespace mirrors `liquid/` rule ids). */
46
+ export const MISSING_LABEL_RULE_ID = "unity/missing-accessible-label" as const;
47
+
48
+ /** The WCAG Success Criteria this rule maps to: 1.1.1 Non-text Content (the missing
49
+ * accessible name) and 4.1.2 Name, Role, Value (a control with no exposed name). The
50
+ * corpus enrichment keys off these SCs for the real-world frequency signal, exactly as
51
+ * the other producers' findings do. */
52
+ const RULE_WCAG: readonly string[] = ["1.1.1", "4.1.2"];
53
+
54
+ /** WCAG SCs for the missing-accessible-label rule (the wcag bridge, analog of
55
+ * `wcagForColorOnlyState`). Stable shape: a function so a caller need not import the array. */
56
+ export function wcagForMissingLabel(): readonly string[] {
57
+ return RULE_WCAG;
58
+ }
59
+
60
+ /** What a caller supplies so a finding can be anchored to its asset. The producer's
61
+ * {@link UnityAsset} already carries both fields, so {@link unityMissingLabelFindings}
62
+ * accepts it directly — but it also needs the raw source, which the resolver reads for
63
+ * the LocalizeStringEvent fields the L1 AST does not capture. */
64
+ export interface MissingLabelContext {
65
+ /** The `.prefab` / `.unity` file the finding is anchored in. */
66
+ readonly file: string;
67
+ /** The asset's parse outcome from the producer (graph, or opaque). */
68
+ readonly parse: UnityAsset["parse"];
69
+ /** The raw Force-Text source the asset was parsed from (the resolver re-reads it for
70
+ * the LocalizeStringEvent dynamic-detection fields). */
71
+ readonly source: string;
72
+ }
73
+
74
+ /**
75
+ * A GameObject is an interactive widget owing an accessible name iff it carries a
76
+ * component resolving to a control host (`button`) OR a Selectable (a component
77
+ * serializing `m_Transition`). A custom/opaque component is never treated as
78
+ * interactive — the precision invariant (correct widget or opaque, never wrong-host).
79
+ */
80
+ function isInteractiveWidget(graph: UnityGraph, widget: UnityGameObject): boolean {
81
+ return widget.components.some((component) => isControlHost(graph, component) || isSelectable(component));
82
+ }
83
+
84
+ /** A component whose built-in identity is a control host (`button` — Button / Toggle). */
85
+ function isControlHost(graph: UnityGraph, component: UnityComponent): boolean {
86
+ const identity = resolveComponentIdentity(graph, component);
87
+ return identity.kind === "widget" && identity.host === "button";
88
+ }
89
+
90
+ /** A Selectable is any component that serializes `m_Transition` (`transition !== null`). */
91
+ function isSelectable(component: UnityComponent): boolean {
92
+ return component.transition !== null;
93
+ }
94
+
95
+ function makeFinding(file: string): Finding {
96
+ return {
97
+ file,
98
+ // A serialized asset has no meaningful source line for a logical widget; the anchor
99
+ // is the file (the asset is the unit), mirroring the color-only rule's convention.
100
+ line: 0,
101
+ ruleId: MISSING_LABEL_RULE_ID,
102
+ message:
103
+ "Interactive uGUI widget has no accessible label — no text-bearing child " +
104
+ "(`TextMeshProUGUI` / `Text`) supplies an accessible name. The control exposes " +
105
+ "no name to assistive technology, so a screen-reader user cannot identify it. " +
106
+ "Add a text child with the control's label, or set the accessible name explicitly.",
107
+ wcag: RULE_WCAG,
108
+ // The static floor's default enforcement (the `decideEnforcement` no-contract
109
+ // default is `block`), matching the sibling Unity rules.
110
+ enforcement: "block",
111
+ provenance: "unity",
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Findings for one Unity asset: one finding per interactive widget whose label resolves
117
+ * to `Absent`. A `static` or `dynamic` label emits nothing (the precision invariant),
118
+ * and an opaque asset (binary / unparseable) yields `[]` — opaque is reported by the
119
+ * producer, not guessed at here.
120
+ */
121
+ export function unityMissingLabelFindings(ctx: MissingLabelContext): Finding[] {
122
+ if (ctx.parse.kind !== "graph") return [];
123
+ const graph = ctx.parse.graph;
124
+ const findings: Finding[] = [];
125
+ for (const widget of graph.gameObjects.values()) {
126
+ if (!isInteractiveWidget(graph, widget)) continue;
127
+ if (resolveUnityLabel(graph, widget, ctx.source).kind === "absent") {
128
+ findings.push(makeFinding(ctx.file));
129
+ }
130
+ }
131
+ return findings;
132
+ }
133
+
134
+ /**
135
+ * Run the missing-accessible-label rule over a whole {@link UnityScanResult} — the entry
136
+ * point the aggregator (`unity-findings.ts`) calls. Consumes the producer's per-asset
137
+ * parse output directly (no re-parse), and is forgiving: an opaque asset simply
138
+ * contributes nothing. The raw source is re-read per asset (the resolver needs the
139
+ * LocalizeStringEvent fields the AST does not capture).
140
+ */
141
+ export async function scanMissingLabel(scan: UnityScanResult): Promise<Finding[]> {
142
+ const findings: Finding[] = [];
143
+ for (const asset of scan.assets) {
144
+ const source = await readAssetSource(asset);
145
+ findings.push(...unityMissingLabelFindings({ file: asset.file, parse: asset.parse, source }));
146
+ }
147
+ return findings;
148
+ }
149
+
150
+ /** Read an asset's raw source for the resolver's dynamic-detection pass. An opaque
151
+ * asset is short-circuited (no graph to resolve), so its source is never read. */
152
+ async function readAssetSource(asset: UnityAsset): Promise<string> {
153
+ if (asset.parse.kind !== "graph") return "";
154
+ const { readFile } = await import("node:fs/promises");
155
+ return readFile(asset.file, "utf8");
156
+ }