@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
package/src/unity-ast.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Unity AST layer — L1 of the Unity static producer (issue #71, ADR 0004), the
|
|
3
|
+
* analog of `liquid-ast.ts` for the Shopify path.
|
|
4
|
+
*
|
|
5
|
+
* It parses a Unity **Force-Text** serialized `.prefab` / `.unity` (scene) file — a
|
|
6
|
+
* YAML 1.1 subset with Unity's `--- !u!<classID> &<fileID>` document tags — into a
|
|
7
|
+
* walkable node graph the structural-absence rules (later children) reason over:
|
|
8
|
+
* GameObjects, their Components, the `m_Children` transform hierarchy, and each
|
|
9
|
+
* component's `m_Script` guid for identity resolution via the built-in-widget
|
|
10
|
+
* registry (`unity-guid-registry.ts`).
|
|
11
|
+
*
|
|
12
|
+
* Two precision seams live here, both honoring the resolver's invariant — map to the
|
|
13
|
+
* correct host or stay OPAQUE, never the wrong host:
|
|
14
|
+
* 1. **Serialization mode.** A binary-serialized (non-Force-Text) asset is opaque
|
|
15
|
+
* by construction; we detect the absence of the `%YAML` text-mode signature and
|
|
16
|
+
* return `{ kind: "opaque", reason: "binary" }` rather than guessing. (ADR 0004.)
|
|
17
|
+
* 2. **Component identity.** A component whose `m_Script` guid is a known built-in
|
|
18
|
+
* resolves to that widget; an unknown guid (a custom MonoBehaviour) resolves
|
|
19
|
+
* OPAQUE. `.meta` resolution of custom components is out of scope (ADR 0004).
|
|
20
|
+
*
|
|
21
|
+
* We hand-roll a line-oriented YAML subset rather than pulling a full YAML library:
|
|
22
|
+
* Unity's `!u!`/`&fileID` document tags are non-standard and break standard parsers,
|
|
23
|
+
* and we only need a small, well-defined field surface (object header, `m_Name`,
|
|
24
|
+
* `m_GameObject`, `m_Children`, `m_Script` guid, `m_text`). One malformed file
|
|
25
|
+
* returns opaque, never throws — one bad asset must not abort a whole-project scan.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { resolveWidgetGuid, type UnityWidgetKind } from "./unity-guid-registry";
|
|
29
|
+
|
|
30
|
+
/** A Unity object's stable in-file id (the `&<fileID>` anchor), as a string key. */
|
|
31
|
+
export type FileId = string;
|
|
32
|
+
|
|
33
|
+
/** A serialized Unity Component (Transform, MonoBehaviour, Renderer, …) attached to
|
|
34
|
+
* a GameObject. Built-in native components (Transform, CanvasRenderer) carry no
|
|
35
|
+
* `m_Script`; MonoBehaviours carry the `m_Script` guid we resolve identity from. */
|
|
36
|
+
export interface UnityComponent {
|
|
37
|
+
/** This component's `&fileID` anchor. */
|
|
38
|
+
readonly fileId: FileId;
|
|
39
|
+
/** The Unity class id from the `!u!<classID>` document tag (e.g. 114 = MonoBehaviour). */
|
|
40
|
+
readonly classId: number;
|
|
41
|
+
/** The serialized type name (the first mapping key after the header, e.g.
|
|
42
|
+
* "MonoBehaviour", "RectTransform"). */
|
|
43
|
+
readonly typeName: string;
|
|
44
|
+
/** The GameObject this component is attached to (`m_GameObject` back-link). */
|
|
45
|
+
readonly gameObjectId: FileId | null;
|
|
46
|
+
/** The `m_Script` guid for a MonoBehaviour, lowercased — the identity key. `null`
|
|
47
|
+
* for native components (no script reference). */
|
|
48
|
+
readonly scriptGuid: string | null;
|
|
49
|
+
/** Child transform `fileID`s from `m_Children` (present on RectTransform/Transform). */
|
|
50
|
+
readonly children: readonly FileId[];
|
|
51
|
+
/** A static `m_text` literal, when present (TMP/Text accessible-name source). The
|
|
52
|
+
* #70 label seam reads this; captured here so the graph is sufficient for it. */
|
|
53
|
+
readonly text: string | null;
|
|
54
|
+
/**
|
|
55
|
+
* The uGUI `Selectable.m_Transition` enum, when present — the field every
|
|
56
|
+
* Selectable (Button/Toggle/Slider/…) serializes: `0`=None, `1`=ColorTint,
|
|
57
|
+
* `2`=SpriteSwap, `3`=Animation. `null` on any component that has no
|
|
58
|
+
* `m_Transition` (i.e. not a Selectable: Image, Transform, Text). Captured here
|
|
59
|
+
* so the graph is sufficient for the color-only-state rule (#73) to read it
|
|
60
|
+
* without a re-parse, and `null` IS the "not a Selectable" signal.
|
|
61
|
+
*/
|
|
62
|
+
readonly transition: number | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A serialized GameObject — the node the hierarchy is built from. */
|
|
66
|
+
export interface UnityGameObject {
|
|
67
|
+
/** This GameObject's `&fileID` anchor. */
|
|
68
|
+
readonly fileId: FileId;
|
|
69
|
+
/** `m_Name` — the GameObject's name in the hierarchy. */
|
|
70
|
+
readonly name: string;
|
|
71
|
+
/** The `fileID`s of the components attached to it (`m_Component` list). */
|
|
72
|
+
readonly componentIds: readonly FileId[];
|
|
73
|
+
/** The resolved component objects attached to it (filled after the graph is built). */
|
|
74
|
+
readonly components: readonly UnityComponent[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The parsed node graph — GameObjects + Components, indexed by `fileID`, with the
|
|
78
|
+
* transform hierarchy walkable via `childGameObjects`. */
|
|
79
|
+
export interface UnityGraph {
|
|
80
|
+
readonly gameObjects: ReadonlyMap<FileId, UnityGameObject>;
|
|
81
|
+
readonly components: ReadonlyMap<FileId, UnityComponent>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse outcome. A binary asset, or one we cannot parse, is OPAQUE with a reason —
|
|
86
|
+
* never a partial/guessed graph (the precision invariant). The caller decides whether
|
|
87
|
+
* to surface the opaque state (it must, per ADR 0004 — opaque is reported, not
|
|
88
|
+
* silently skipped).
|
|
89
|
+
*/
|
|
90
|
+
export type UnityParseResult =
|
|
91
|
+
| { readonly kind: "graph"; readonly graph: UnityGraph }
|
|
92
|
+
| { readonly kind: "opaque"; readonly reason: "binary" | "parse-error"; readonly message?: string };
|
|
93
|
+
|
|
94
|
+
/** A component's resolved identity — a known built-in widget, or OPAQUE (a custom
|
|
95
|
+
* MonoBehaviour, or a native component with no widget meaning). Never a wrong widget. */
|
|
96
|
+
export type ComponentIdentity =
|
|
97
|
+
| { readonly kind: "widget"; readonly widget: UnityWidgetKind; readonly host: string }
|
|
98
|
+
| { readonly kind: "opaque" };
|
|
99
|
+
|
|
100
|
+
/** The Force-Text signature Unity writes at the top of a text-serialized asset. Its
|
|
101
|
+
* absence is the binary (non-Force-Text) tell. */
|
|
102
|
+
const FORCE_TEXT_SIGNATURE = "%YAML";
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Detect whether a serialized asset is Force-Text (readable YAML) or binary. Binary
|
|
106
|
+
* Unity assets do not begin with the `%YAML` directive; Force-Text always does. This
|
|
107
|
+
* is the one real precondition external-static analysis carries (ADR 0004) — and it
|
|
108
|
+
* is detectable, so we stay opaque on binary rather than guess.
|
|
109
|
+
*/
|
|
110
|
+
export function isForceText(source: string): boolean {
|
|
111
|
+
return source.trimStart().startsWith(FORCE_TEXT_SIGNATURE);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const HEADER_RE = /^--- !u!(\d+) &(\d+)/;
|
|
115
|
+
const FILEID_RE = /fileID:\s*(\d+)/;
|
|
116
|
+
const GUID_RE = /guid:\s*([0-9a-fA-F]{32})/;
|
|
117
|
+
|
|
118
|
+
/** Strip a trailing YAML comment and surrounding whitespace from a scalar value. */
|
|
119
|
+
function scalar(raw: string): string {
|
|
120
|
+
return raw.replace(/\s+#.*$/, "").trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One raw `--- !u!N &id` document block, pre-split for field extraction. */
|
|
124
|
+
interface RawDoc {
|
|
125
|
+
readonly classId: number;
|
|
126
|
+
readonly fileId: FileId;
|
|
127
|
+
readonly typeName: string;
|
|
128
|
+
readonly lines: readonly string[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Split a Force-Text source into its `--- !u!N &id` document blocks. */
|
|
132
|
+
function splitDocuments(source: string): RawDoc[] {
|
|
133
|
+
const docs: RawDoc[] = [];
|
|
134
|
+
const lines = source.split("\n");
|
|
135
|
+
let current: { classId: number; fileId: FileId; body: string[] } | null = null;
|
|
136
|
+
|
|
137
|
+
const flush = () => {
|
|
138
|
+
if (!current) return;
|
|
139
|
+
const typeName = (current.body.find((l) => /^\S/.test(l)) ?? "").replace(/:\s*$/, "").trim();
|
|
140
|
+
docs.push({ classId: current.classId, fileId: current.fileId, typeName, lines: current.body });
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
const header = HEADER_RE.exec(line);
|
|
145
|
+
if (header) {
|
|
146
|
+
flush();
|
|
147
|
+
current = { classId: Number(header[1]), fileId: header[2]!, body: [] };
|
|
148
|
+
} else if (current) {
|
|
149
|
+
current.body.push(line);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
flush();
|
|
153
|
+
return docs;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Read the `m_Children` block: every `fileID` on the `- {fileID: N}` items that
|
|
157
|
+
* follow the `m_Children:` key, stopping at the next sibling key (dedent to a
|
|
158
|
+
* `m_`-prefixed line). */
|
|
159
|
+
function readChildren(lines: readonly string[]): FileId[] {
|
|
160
|
+
const out: FileId[] = [];
|
|
161
|
+
let inBlock = false;
|
|
162
|
+
for (const line of lines) {
|
|
163
|
+
if (/^\s*m_Children:\s*$/.test(line)) {
|
|
164
|
+
inBlock = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (inBlock) {
|
|
168
|
+
if (/^\s*-\s*\{fileID:/.test(line)) {
|
|
169
|
+
const m = FILEID_RE.exec(line);
|
|
170
|
+
if (m && m[1] !== "0") out.push(m[1]!);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// `m_Children: []` inline, or the next key — block is over.
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read the `m_Component` list: the `component: {fileID: N}` entries. */
|
|
181
|
+
function readComponentIds(lines: readonly string[]): FileId[] {
|
|
182
|
+
const out: FileId[] = [];
|
|
183
|
+
let inBlock = false;
|
|
184
|
+
for (const line of lines) {
|
|
185
|
+
if (/^\s*m_Component:\s*$/.test(line)) {
|
|
186
|
+
inBlock = true;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (inBlock) {
|
|
190
|
+
if (/^\s*-\s*component:/.test(line)) {
|
|
191
|
+
const m = FILEID_RE.exec(line);
|
|
192
|
+
if (m && m[1] !== "0") out.push(m[1]!);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (/^\s*-/.test(line)) continue; // tolerate other list shapes
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Read the Selectable `m_Transition` enum from a component block, or `null` if the
|
|
204
|
+
* block has no `m_Transition` (it is not a Selectable). The match is anchored to the
|
|
205
|
+
* `m_Transition:` mapping key at the component's own indent, never a nested key, so a
|
|
206
|
+
* non-numeric or absent value yields `null` — a Selectable always serializes an
|
|
207
|
+
* integer here, and `null` is the unambiguous "not a Selectable" signal.
|
|
208
|
+
*/
|
|
209
|
+
function readTransition(lines: readonly string[]): number | null {
|
|
210
|
+
const raw = field(lines, "m_Transition");
|
|
211
|
+
if (raw === null) return null;
|
|
212
|
+
const value = Number(raw);
|
|
213
|
+
return Number.isInteger(value) ? value : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** First captured scalar for `key:` in the block, or null. */
|
|
217
|
+
function field(lines: readonly string[], key: string): string | null {
|
|
218
|
+
const re = new RegExp(`^\\s*${key}:\\s*(.*)$`);
|
|
219
|
+
for (const line of lines) {
|
|
220
|
+
const m = re.exec(line);
|
|
221
|
+
if (m) return scalar(m[1] ?? "");
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Parse a Unity serialized asset into a node graph, or report it OPAQUE.
|
|
228
|
+
*
|
|
229
|
+
* Returns `{ kind: "opaque", reason: "binary" }` for a non-Force-Text asset (the
|
|
230
|
+
* detectable precondition, ADR 0004), `{ kind: "opaque", reason: "parse-error" }` if
|
|
231
|
+
* the text is unparseable, and `{ kind: "graph", graph }` otherwise. Never throws —
|
|
232
|
+
* one bad asset must not take down a whole-project scan.
|
|
233
|
+
*/
|
|
234
|
+
export function parseUnityDocument(source: string): UnityParseResult {
|
|
235
|
+
if (!isForceText(source)) {
|
|
236
|
+
return { kind: "opaque", reason: "binary" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const docs = splitDocuments(source);
|
|
241
|
+
const components = new Map<FileId, UnityComponent>();
|
|
242
|
+
const gameObjectsRaw = new Map<FileId, { name: string; componentIds: FileId[] }>();
|
|
243
|
+
|
|
244
|
+
for (const doc of docs) {
|
|
245
|
+
if (doc.typeName === "GameObject") {
|
|
246
|
+
gameObjectsRaw.set(doc.fileId, {
|
|
247
|
+
name: field(doc.lines, "m_Name") ?? "",
|
|
248
|
+
componentIds: readComponentIds(doc.lines),
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
// Everything else is a component attached to some GameObject.
|
|
253
|
+
const goLine = doc.lines.find((l) => /^\s*m_GameObject:/.test(l));
|
|
254
|
+
const gameObjectId = goLine
|
|
255
|
+
? (() => {
|
|
256
|
+
const m = FILEID_RE.exec(goLine);
|
|
257
|
+
return m && m[1] !== "0" ? m[1]! : null;
|
|
258
|
+
})()
|
|
259
|
+
: null;
|
|
260
|
+
const scriptLine = doc.lines.find((l) => /^\s*m_Script:/.test(l));
|
|
261
|
+
const guidMatch = scriptLine ? GUID_RE.exec(scriptLine) : null;
|
|
262
|
+
components.set(doc.fileId, {
|
|
263
|
+
fileId: doc.fileId,
|
|
264
|
+
classId: doc.classId,
|
|
265
|
+
typeName: doc.typeName,
|
|
266
|
+
gameObjectId,
|
|
267
|
+
scriptGuid: guidMatch ? guidMatch[1]!.toLowerCase() : null,
|
|
268
|
+
children: readChildren(doc.lines),
|
|
269
|
+
text: field(doc.lines, "m_text"),
|
|
270
|
+
transition: readTransition(doc.lines),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const gameObjects = new Map<FileId, UnityGameObject>();
|
|
275
|
+
for (const [fileId, raw] of gameObjectsRaw) {
|
|
276
|
+
const attached = raw.componentIds
|
|
277
|
+
.map((id) => components.get(id))
|
|
278
|
+
.filter((c): c is UnityComponent => c != null);
|
|
279
|
+
gameObjects.set(fileId, {
|
|
280
|
+
fileId,
|
|
281
|
+
name: raw.name,
|
|
282
|
+
componentIds: raw.componentIds,
|
|
283
|
+
components: attached,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { kind: "graph", graph: { gameObjects, components } };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return {
|
|
290
|
+
kind: "opaque",
|
|
291
|
+
reason: "parse-error",
|
|
292
|
+
message: error instanceof Error ? error.message : String(error),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Resolve a component's identity via the built-in-widget GUID registry. A known
|
|
299
|
+
* built-in guid → that widget; an unknown guid (custom MonoBehaviour) or a native
|
|
300
|
+
* component with no script → OPAQUE. Never maps to a wrong widget — the precision
|
|
301
|
+
* invariant at the identity seam.
|
|
302
|
+
*/
|
|
303
|
+
export function resolveComponentIdentity(
|
|
304
|
+
_graph: UnityGraph,
|
|
305
|
+
component: UnityComponent,
|
|
306
|
+
): ComponentIdentity {
|
|
307
|
+
if (component.scriptGuid == null) return { kind: "opaque" };
|
|
308
|
+
const widget = resolveWidgetGuid(component.scriptGuid);
|
|
309
|
+
if (!widget) return { kind: "opaque" };
|
|
310
|
+
return { kind: "widget", widget: widget.widget, host: widget.host };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Walk a GameObject's transform children one level down. Unity's hierarchy lives on
|
|
315
|
+
* the *transform* components, not the GameObjects: a GameObject's RectTransform/
|
|
316
|
+
* Transform lists child *transform* `fileID`s in `m_Children`, and each child
|
|
317
|
+
* transform's `m_GameObject` points back to the child GameObject. This resolves that
|
|
318
|
+
* indirection so callers walk GameObject→GameObject directly.
|
|
319
|
+
*/
|
|
320
|
+
export function childGameObjects(graph: UnityGraph, parent: UnityGameObject): UnityGameObject[] {
|
|
321
|
+
const out: UnityGameObject[] = [];
|
|
322
|
+
for (const component of parent.components) {
|
|
323
|
+
for (const childTransformId of component.children) {
|
|
324
|
+
const childTransform = graph.components.get(childTransformId);
|
|
325
|
+
if (!childTransform?.gameObjectId) continue;
|
|
326
|
+
const childGo = graph.gameObjects.get(childTransform.gameObjectId);
|
|
327
|
+
if (childGo) out.push(childGo);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Unity finding-emission aggregator (#88, foundation of epic #87) — the single
|
|
3
|
+
* in-process function that turns a scanned Unity project into one canonical `Finding[]`.
|
|
4
|
+
* The `collect-unity` analog of `scanLiquid`/`scanSwift`: every Unity consumer (the
|
|
5
|
+
* future `check-unity` CLI verb #89, the corpus gate #90, the MCP/hook surfaces #92)
|
|
6
|
+
* calls this one function, so there is exactly one place Unity findings are produced and
|
|
7
|
+
* exactly one shape they take (the one-collector-one-shape rule, ADR 0001/0002).
|
|
8
|
+
*
|
|
9
|
+
* It runs `scanUnity` (`collect-unity.ts`) once, then runs the THREE Unity rule sources
|
|
10
|
+
* over the scan and reconciles them onto the shared `core.ts` `Finding` shape:
|
|
11
|
+
*
|
|
12
|
+
* 1. `scanColorOnlyState` (`unity-rule-color-only.ts`) — already returns canonical
|
|
13
|
+
* `Finding[]`; passed through unchanged.
|
|
14
|
+
* 2. `scanMissingLabel` (`unity-rule-missing-label.ts`) — the new per-widget rule on
|
|
15
|
+
* the resolver's `Absent` state; already canonical `Finding[]`; passed through.
|
|
16
|
+
* 3. `runUnityBaselineRules` (`unity-rules-baseline.ts`) — returns the project-scoped
|
|
17
|
+
* `UnityProjectFinding[]` (`line: 0`, `provenance: "unity"`, no `layer`). Adapted to
|
|
18
|
+
* a canonical `Finding` AT THIS SEAM ({@link adaptProjectFinding}), stamping
|
|
19
|
+
* `layer: "floor"`. The shared `Finding` / `UnityProjectFinding` types are NOT
|
|
20
|
+
* widened to force-fit — the field mapping lives at the boundary (epic #87 resolved
|
|
21
|
+
* question: adapt at the aggregator seam).
|
|
22
|
+
*
|
|
23
|
+
* Every finding the aggregator returns carries `provenance: "unity"` (already on each
|
|
24
|
+
* source) and `layer: "floor"` — Unity findings are deterministic static-floor findings
|
|
25
|
+
* that gate the CLI exit code, exactly like the other producers' floor findings.
|
|
26
|
+
*
|
|
27
|
+
* Forgiving by construction (mirrors `scanUnity` / the other collectors): a missing
|
|
28
|
+
* project dir is an empty scan (`scanUnity` yields no assets; `runUnityBaselineRules`
|
|
29
|
+
* returns `[]` on a non-existent dir), so the whole aggregate is `[]`, never a throw.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { scanUnity } from "./collect-unity";
|
|
33
|
+
import type { Finding } from "./core";
|
|
34
|
+
import { scanColorOnlyState } from "./unity-rule-color-only";
|
|
35
|
+
import { scanMissingLabel } from "./unity-rule-missing-label";
|
|
36
|
+
import { runUnityBaselineRules, type UnityProjectFinding } from "./unity-rules-baseline";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Adapt a project-scoped {@link UnityProjectFinding} to the canonical {@link Finding}
|
|
40
|
+
* shape at the aggregator boundary. The two shapes already agree field-for-field on the
|
|
41
|
+
* surface the report reads (`file`, `line`, `ruleId`, `message`, `wcag`, `enforcement`,
|
|
42
|
+
* `provenance`); this stamps the one missing field — `layer: "floor"` — without widening
|
|
43
|
+
* either type. `provenance` is already the `"unity"` literal both shapes share.
|
|
44
|
+
*/
|
|
45
|
+
function adaptProjectFinding(finding: UnityProjectFinding): Finding {
|
|
46
|
+
return {
|
|
47
|
+
file: finding.file,
|
|
48
|
+
line: finding.line,
|
|
49
|
+
ruleId: finding.ruleId,
|
|
50
|
+
message: finding.message,
|
|
51
|
+
wcag: finding.wcag,
|
|
52
|
+
enforcement: finding.enforcement,
|
|
53
|
+
provenance: finding.provenance,
|
|
54
|
+
layer: "floor",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Stamp `layer: "floor"` on an already-canonical Unity rule finding (color-only,
|
|
59
|
+
* missing-label). The per-widget rules build the `Finding` shape directly but leave
|
|
60
|
+
* `layer` unset (the floor default); the aggregator makes it explicit so every Unity
|
|
61
|
+
* finding carries the same exit-code-affecting layer tag. */
|
|
62
|
+
function asFloor(finding: Finding): Finding {
|
|
63
|
+
return finding.layer === "floor" ? finding : { ...finding, layer: "floor" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Aggregate every Unity rule source over a scanned project directory into one flat
|
|
68
|
+
* canonical `Finding[]` (all `provenance: "unity"`, `layer: "floor"`). The single seam
|
|
69
|
+
* the rest of the Unity-wiring epic plugs into.
|
|
70
|
+
*
|
|
71
|
+
* - per-widget color-only-state findings (`scanColorOnlyState`),
|
|
72
|
+
* - per-widget missing-accessible-label findings (`scanMissingLabel`),
|
|
73
|
+
* - project-level baseline findings (`runUnityBaselineRules`, adapted at the seam).
|
|
74
|
+
*
|
|
75
|
+
* A missing/unreadable project dir yields `[]` (an empty scan), never a throw.
|
|
76
|
+
*/
|
|
77
|
+
export async function collectUnityFindings(dir: string): Promise<Finding[]> {
|
|
78
|
+
const scan = await scanUnity(dir);
|
|
79
|
+
|
|
80
|
+
const [colorOnly, missingLabel, baseline] = await Promise.all([
|
|
81
|
+
Promise.resolve(scanColorOnlyState(scan)),
|
|
82
|
+
scanMissingLabel(scan),
|
|
83
|
+
runUnityBaselineRules(dir),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
return [
|
|
87
|
+
...colorOnly.map(asFloor),
|
|
88
|
+
...missingLabel.map(asFloor),
|
|
89
|
+
...baseline.map(adaptProjectFinding),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Unity built-in-widget GUID registry — the Unity analog of `registry.ts`'s
|
|
3
|
+
* known-library wrapper→host table, the deterministic fast path for component
|
|
4
|
+
* identity (issue #71, ADR 0004).
|
|
5
|
+
*
|
|
6
|
+
* Unity serializes a `MonoBehaviour`'s type as an `m_Script: {guid: <32-hex>}`
|
|
7
|
+
* reference into the script's `.meta`. For **built-in** uGUI/TMP components those
|
|
8
|
+
* guids are *stable Unity constants — identical in every project* (the engine ships
|
|
9
|
+
* them), so built-in widget identity is a pure table lookup here, never per-project
|
|
10
|
+
* `.meta` resolution. (`.meta` resolution for *project-custom* MonoBehaviours is a
|
|
11
|
+
* later capability and explicitly out of scope for this slice — ADR 0004.)
|
|
12
|
+
*
|
|
13
|
+
* The precision invariant the whole resolver lives by holds at this seam: a guid in
|
|
14
|
+
* the table resolves to the correct built-in widget; a guid NOT in the table (a
|
|
15
|
+
* custom MonoBehaviour) resolves to `undefined` here, which the caller treats as
|
|
16
|
+
* OPAQUE — it is never mapped to a wrong widget. Coverage is pure DATA: adding a
|
|
17
|
+
* built-in widget is a new row, never a code change (the `registry.ts` discipline).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The accessibility-relevant widget kinds we resolve a built-in guid to. The
|
|
21
|
+
* `host` is the WAI-ARIA/HTML host the structural-absence rules (later children)
|
|
22
|
+
* will reason about — a Button is interactive (`button`), an Image is `img`, a text
|
|
23
|
+
* widget carries the accessible name (`text`). */
|
|
24
|
+
export type UnityWidgetKind =
|
|
25
|
+
| "Button"
|
|
26
|
+
| "Image"
|
|
27
|
+
| "RawImage"
|
|
28
|
+
| "TextMeshProUGUI"
|
|
29
|
+
| "Text"
|
|
30
|
+
| "Toggle";
|
|
31
|
+
|
|
32
|
+
/** One built-in widget the registry resolves. `guid` is the stable Unity engine
|
|
33
|
+
* constant (lowercase 32-hex); `widget` is its kind; `host` is the host element the
|
|
34
|
+
* rules map it to (the bridge to the existing engine's host vocabulary). */
|
|
35
|
+
export interface UnityBuiltinWidget {
|
|
36
|
+
/** The stable cross-project Unity engine guid (lowercase, 32 hex chars). */
|
|
37
|
+
readonly guid: string;
|
|
38
|
+
/** The widget kind this guid denotes. */
|
|
39
|
+
readonly widget: UnityWidgetKind;
|
|
40
|
+
/** The accessibility host the structural-absence rules reason over. */
|
|
41
|
+
readonly host: "button" | "img" | "text";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The seed table of built-in uGUI/TMP widget guids.
|
|
46
|
+
*
|
|
47
|
+
* Provenance — every row is grounded, not guessed (the precision invariant forbids a
|
|
48
|
+
* speculative guid mapping a custom component to a wrong host):
|
|
49
|
+
* - Button / Image / TextMeshProUGUI — the three verified constants on issue #71
|
|
50
|
+
* (real-corpus evidence, ADR 0004 §Context).
|
|
51
|
+
* - Text (legacy uGUI) / Toggle — verified against the #71 corpus anchor
|
|
52
|
+
* (`UnityTechnologies/open-project-1` @ 608eac98) by their distinctive serialized
|
|
53
|
+
* fields (`m_FontData`/`m_Text` for Text; `m_IsOn`/`m_Group` for Toggle).
|
|
54
|
+
*
|
|
55
|
+
* Slider, Dropdown, InputField, etc. are deliberately omitted until a row can be
|
|
56
|
+
* grounded the same way — an unverified guid would defeat the precision invariant.
|
|
57
|
+
* Extend by appending a verified row.
|
|
58
|
+
*/
|
|
59
|
+
export const UNITY_BUILTIN_GUIDS: readonly UnityBuiltinWidget[] = [
|
|
60
|
+
{ guid: "4e29b1a8efbd4b44bb3f3716e73f07ff", widget: "Button", host: "button" },
|
|
61
|
+
{ guid: "fe87c0e1cc204ed48ad3b37840f39efc", widget: "Image", host: "img" },
|
|
62
|
+
{ guid: "f4688fdb7df04437aeb418b961361dc5", widget: "TextMeshProUGUI", host: "text" },
|
|
63
|
+
{ guid: "5f7201a12d95ffc409449d95f23cf332", widget: "Text", host: "text" },
|
|
64
|
+
{ guid: "9085046f02f69544eb97fd06b6048fe2", widget: "Toggle", host: "button" },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/** Guid → widget index, built once. Keys are normalized (lowercased) so a lookup is
|
|
68
|
+
* insensitive to the casing Unity happens to emit. */
|
|
69
|
+
const BY_GUID: ReadonlyMap<string, UnityBuiltinWidget> = new Map(
|
|
70
|
+
UNITY_BUILTIN_GUIDS.map((w) => [w.guid, w] as const),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Resolve an `m_Script` guid to its built-in widget, or `undefined` if it is not a
|
|
75
|
+
* known built-in. `undefined` is the OPAQUE signal: the caller must treat an
|
|
76
|
+
* unresolved guid as opaque and never map it to a host — the precision invariant
|
|
77
|
+
* (map to the correct built-in or stay opaque; never wrong-host). Tolerant of
|
|
78
|
+
* surrounding whitespace and casing so a raw serialized value resolves directly.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveWidgetGuid(guid: string): UnityBuiltinWidget | undefined {
|
|
81
|
+
return BY_GUID.get(guid.trim().toLowerCase());
|
|
82
|
+
}
|