@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.
- package/README.md +285 -0
- package/bin/a11y.mjs +36 -0
- package/bin/diff-scope.mjs +29 -0
- package/data/baseline-rules.json +892 -0
- package/package.json +68 -0
- package/src/agent-lane.ts +138 -0
- package/src/agents-block.ts +157 -0
- package/src/baseline/gen-baseline.ts +166 -0
- package/src/cli.ts +1026 -0
- package/src/collect-dom.ts +119 -0
- package/src/collect-liquid.ts +103 -0
- package/src/collect-swift.ts +227 -0
- package/src/collect-unity.ts +99 -0
- package/src/collect.ts +54 -0
- package/src/commands.ts +314 -0
- package/src/config-scan.ts +177 -0
- package/src/contract.ts +355 -0
- package/src/core.ts +546 -0
- package/src/detect-stack.ts +207 -0
- package/src/diff-scope-cli.ts +12 -0
- package/src/diff-scope.ts +150 -0
- package/src/emit-contract.ts +181 -0
- package/src/enforce.ts +1125 -0
- package/src/eslint-plugin-jsx-a11y.d.ts +11 -0
- package/src/evidence.ts +308 -0
- package/src/github-identity.ts +201 -0
- package/src/hook.ts +242 -0
- package/src/impact-gate.ts +107 -0
- package/src/imports-resolve.ts +248 -0
- package/src/index.ts +183 -0
- package/src/liquid-ast.ts +203 -0
- package/src/liquid-rules.ts +691 -0
- package/src/mcp.ts +363 -0
- package/src/module-scope.ts +137 -0
- package/src/phone-home.ts +470 -0
- package/src/pr-comment.ts +250 -0
- package/src/pr-summary-cli.ts +182 -0
- package/src/pr-summary.ts +291 -0
- package/src/registry.ts +605 -0
- package/src/reporter/contract.ts +154 -0
- package/src/reporter/finding.ts +87 -0
- package/src/reporter/github-adapter.ts +183 -0
- package/src/reporter/null-adapter.ts +51 -0
- package/src/reporter/registry.ts +22 -0
- package/src/reporter-cli.ts +48 -0
- package/src/resolve-components.ts +579 -0
- package/src/runner/budget.ts +90 -0
- package/src/runner/codegraph-lookup.test.ts +93 -0
- package/src/runner/codegraph-lookup.ts +197 -0
- package/src/runner/index.ts +86 -0
- package/src/runner/lookup.ts +69 -0
- package/src/runner/provider.ts +72 -0
- package/src/runner/providers/anthropic.ts +168 -0
- package/src/runner/providers/openai.ts +191 -0
- package/src/runner/reasoner.ts +73 -0
- package/src/runner/reasoning/index.ts +53 -0
- package/src/runner/reasoning/prompt.ts +200 -0
- package/src/runner/reasoning/react.ts +149 -0
- package/src/runner/reasoning/shopify.ts +214 -0
- package/src/runner/reasoning/skills-reasoner.ts +117 -0
- package/src/runner/reasoning/types.ts +99 -0
- package/src/runner/runner.ts +203 -0
- package/src/sarif.ts +148 -0
- package/src/source-identity.ts +0 -0
- package/src/source-trace.ts +814 -0
- package/src/suggest.ts +328 -0
- package/src/suppression-ranges.ts +354 -0
- package/src/suppressor-map.ts +189 -0
- package/src/suppressors.ts +284 -0
- package/src/tsconfig-aliases.ts +155 -0
- package/src/unity-ast.ts +331 -0
- package/src/unity-findings.ts +91 -0
- package/src/unity-guid-registry.ts +82 -0
- package/src/unity-label-resolve.ts +249 -0
- package/src/unity-rule-color-only.ts +127 -0
- package/src/unity-rule-missing-label.ts +156 -0
- package/src/unity-rules-baseline.ts +273 -0
- package/src/wcag-map.ts +35 -0
- package/src/wcag-tags.ts +35 -0
- 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
|
+
}
|