@binclusive/a11y 0.1.0 → 0.1.2
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 +25 -13
- package/package.json +8 -3
- package/src/cli.ts +338 -104
- package/src/collect-android-xml.ts +447 -0
- package/src/contract.ts +9 -4
- package/src/core.ts +5 -1
- package/src/decisions-lint.ts +137 -0
- package/src/detect-stack.ts +70 -2
- package/src/diff-scope.ts +42 -20
- package/src/enforce.ts +18 -6
- package/src/finding-voice.ts +66 -0
- package/src/pr-comment.ts +30 -5
- package/src/reporter/finding.ts +7 -2
- package/src/reporter/github-adapter.ts +16 -2
- package/src/runner/runner.ts +3 -0
- package/src/sarif.ts +68 -9
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Android XML layout STATIC collector — an IN-PROCESS producer of
|
|
3
|
+
* {@link Finding}s, parallel to `collect-swift.ts` (SwiftUI) and
|
|
4
|
+
* `collect-liquid.ts` (Shopify Liquid).
|
|
5
|
+
*
|
|
6
|
+
* Unlike the SwiftUI collector — which shells to an out-of-process SwiftSyntax
|
|
7
|
+
* binary because SwiftSyntax can't run from Node — Android layouts are plain XML
|
|
8
|
+
* and parse perfectly well in-process, so there is NO second toolchain here: a
|
|
9
|
+
* thin line-aware element scanner ({@link parseAndroidLayout}) walks each
|
|
10
|
+
* layout file into a node tree, and two structural-absence rules read each
|
|
11
|
+
* element's attributes (and, for controls, its subtree) to decide whether it
|
|
12
|
+
* carries an accessible name.
|
|
13
|
+
*
|
|
14
|
+
* The two rules mirror the SwiftUI two-rule shape (#109):
|
|
15
|
+
* - `android-xml/image-no-label` (WCAG 1.1.1) — an image-presenting widget
|
|
16
|
+
* (`ImageView` / `ImageButton`) that exposes no `android:contentDescription`.
|
|
17
|
+
* A decorative opt-out — `contentDescription="@null"` or
|
|
18
|
+
* `importantForAccessibility="no"` — is honored, not flagged.
|
|
19
|
+
* - `android-xml/control-no-name` (WCAG 4.1.2) — an interactive control (a
|
|
20
|
+
* `Button` / `ImageButton`, or any element marked `android:clickable="true"`)
|
|
21
|
+
* that exposes no accessible name. The name may come from the control itself
|
|
22
|
+
* (`contentDescription` / `android:text` / `android:hint`) OR — for a
|
|
23
|
+
* clickable CONTAINER — from a descendant that carries text or a
|
|
24
|
+
* contentDescription (Android announces a clickable group by its labeled
|
|
25
|
+
* children). This descendant-climb is the Android analog of the SwiftUI
|
|
26
|
+
* collector's ancestor-climb: it stops a label on a child from reading as a
|
|
27
|
+
* false positive on the wrapping clickable group.
|
|
28
|
+
*
|
|
29
|
+
* Both rules honor Android lint's own suppression seam: `tools:ignore="ContentDescription"`
|
|
30
|
+
* on an element — or on any of its ancestors, exactly as Android Studio's lint
|
|
31
|
+
* scopes a suppression to a subtree — silences both rules for it, so the
|
|
32
|
+
* collector never re-flags what the team has already deliberately waived.
|
|
33
|
+
*
|
|
34
|
+
* An `ImageButton` is BOTH an image widget and a control, so an unlabeled one
|
|
35
|
+
* yields two findings on the same line (one per rule) — the proven prototype
|
|
36
|
+
* behavior the `experiments/android-matrix` corpus reproduces.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
40
|
+
import { join, resolve, sep } from "node:path";
|
|
41
|
+
import { contractForFiles, enforcementFor } from "./config-scan";
|
|
42
|
+
import type { Finding } from "./core";
|
|
43
|
+
|
|
44
|
+
/** The two Android-XML static rule ids this collector emits. */
|
|
45
|
+
export type AndroidXmlRuleId = "android-xml/image-no-label" | "android-xml/control-no-name";
|
|
46
|
+
|
|
47
|
+
/** Build/generated dirs that are never layout source — skipped on the walk. The
|
|
48
|
+
* Android ones (`build`, `.gradle`, `.idea`) hold generated/merged resources;
|
|
49
|
+
* the rest match the other collectors' skip set. */
|
|
50
|
+
const SKIP_DIRS = new Set([
|
|
51
|
+
"node_modules",
|
|
52
|
+
".git",
|
|
53
|
+
"dist",
|
|
54
|
+
"build",
|
|
55
|
+
".gradle",
|
|
56
|
+
".idea",
|
|
57
|
+
".cxx",
|
|
58
|
+
"intermediates",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A layout XML file is one named `*.xml` living directly inside a `res/layout…`
|
|
63
|
+
* directory: `res/layout/foo.xml`, `res/layout-land/foo.xml`,
|
|
64
|
+
* `res/layout-sw600dp/foo.xml`, etc. The resource-qualifier suffix on `layout`
|
|
65
|
+
* is arbitrary (orientation, size, locale…), so we match any directory whose
|
|
66
|
+
* name is `layout` or starts with `layout-`, and require its parent to be `res`
|
|
67
|
+
* — that pairing is what distinguishes a real Android layout resource from an
|
|
68
|
+
* unrelated `layout/` directory elsewhere in a tree.
|
|
69
|
+
*/
|
|
70
|
+
export function isAndroidLayoutFile(absPath: string): boolean {
|
|
71
|
+
if (!absPath.endsWith(".xml")) return false;
|
|
72
|
+
const parts = absPath.split(sep);
|
|
73
|
+
if (parts.length < 3) return false;
|
|
74
|
+
const dir = parts[parts.length - 2] as string;
|
|
75
|
+
const parent = parts[parts.length - 3] as string;
|
|
76
|
+
const isLayoutDir = dir === "layout" || dir.startsWith("layout-");
|
|
77
|
+
return parent === "res" && isLayoutDir;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Recursively collect the `layout` resource `.xml` files under `dir`, skipping
|
|
82
|
+
* build/generated dirs. A missing or unreadable directory yields `[]` rather
|
|
83
|
+
* than throwing — a non-existent scan target is an empty scan, the forgiving
|
|
84
|
+
* contract the other collectors give the CLI.
|
|
85
|
+
*/
|
|
86
|
+
export async function collectAndroidLayoutFiles(dir: string): Promise<string[]> {
|
|
87
|
+
const out: string[] = [];
|
|
88
|
+
let entries: Awaited<ReturnType<typeof readdir>>;
|
|
89
|
+
try {
|
|
90
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
91
|
+
} catch {
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
const full = join(dir, entry.name);
|
|
96
|
+
if (entry.isDirectory()) {
|
|
97
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
98
|
+
out.push(...(await collectAndroidLayoutFiles(full)));
|
|
99
|
+
} else if (entry.isFile() && isAndroidLayoutFile(full)) {
|
|
100
|
+
out.push(full);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One element from an Android layout, as a tree node: its local widget name (the
|
|
108
|
+
* last `.`-segment of a possibly-qualified tag —
|
|
109
|
+
* `androidx.appcompat.widget.AppCompatImageButton` → `AppCompatImageButton`),
|
|
110
|
+
* its attribute map (raw `name → value`, namespace prefix kept), the 1-based
|
|
111
|
+
* source `line` the `<` sits on (the location the rules report), and its child
|
|
112
|
+
* elements (empty for a self-closing/leaf element).
|
|
113
|
+
*/
|
|
114
|
+
export interface AndroidNode {
|
|
115
|
+
readonly name: string;
|
|
116
|
+
readonly line: number;
|
|
117
|
+
readonly attrs: ReadonlyMap<string, string>;
|
|
118
|
+
readonly children: readonly AndroidNode[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface MutableNode {
|
|
122
|
+
name: string;
|
|
123
|
+
line: number;
|
|
124
|
+
attrs: Map<string, string>;
|
|
125
|
+
children: MutableNode[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse an Android layout XML string into a tree of element nodes, line-aware.
|
|
130
|
+
*
|
|
131
|
+
* A hand-rolled scanner (not a DOM parser) because the rules need the START-tag
|
|
132
|
+
* line + attributes + nesting, and a streaming scan keeps the exact line number
|
|
133
|
+
* of each `<` — the location the prototype reported and the corpus baseline
|
|
134
|
+
* pins. The scanner skips the `<?xml …?>` declaration, comments, CDATA, and
|
|
135
|
+
* doctypes; it reads attribute values respecting quotes, so a multi-line start
|
|
136
|
+
* tag (ubiquitous in Android layouts) is parsed as one element anchored on the
|
|
137
|
+
* line of its `<`. Returns the top-level node(s) — usually one root, but a
|
|
138
|
+
* `<merge>` or malformed file may yield several.
|
|
139
|
+
*/
|
|
140
|
+
export function parseAndroidLayout(source: string): AndroidNode[] {
|
|
141
|
+
const roots: MutableNode[] = [];
|
|
142
|
+
const stack: MutableNode[] = [];
|
|
143
|
+
const n = source.length;
|
|
144
|
+
let i = 0;
|
|
145
|
+
let line = 1;
|
|
146
|
+
|
|
147
|
+
const step = (): void => {
|
|
148
|
+
if (source[i] === "\n") line++;
|
|
149
|
+
i++;
|
|
150
|
+
};
|
|
151
|
+
const push = (node: MutableNode): void => {
|
|
152
|
+
const parent = stack[stack.length - 1];
|
|
153
|
+
if (parent) parent.children.push(node);
|
|
154
|
+
else roots.push(node);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
while (i < n) {
|
|
158
|
+
if (source[i] !== "<") {
|
|
159
|
+
step();
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const next = source[i + 1];
|
|
163
|
+
if (next === "?") {
|
|
164
|
+
while (i < n && !(source[i] === "?" && source[i + 1] === ">")) step();
|
|
165
|
+
step();
|
|
166
|
+
step();
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (next === "!") {
|
|
170
|
+
if (source.startsWith("<!--", i)) {
|
|
171
|
+
while (i < n && !source.startsWith("-->", i)) step();
|
|
172
|
+
for (let k = 0; k < 3 && i < n; k++) step();
|
|
173
|
+
} else {
|
|
174
|
+
while (i < n && source[i] !== ">") step();
|
|
175
|
+
if (i < n) step();
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (next === "/") {
|
|
180
|
+
// closing tag </name> — pop one level
|
|
181
|
+
while (i < n && source[i] !== ">") step();
|
|
182
|
+
if (i < n) step();
|
|
183
|
+
if (stack.length > 0) stack.pop();
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// an element start tag
|
|
188
|
+
const tagLine = line;
|
|
189
|
+
step(); // '<'
|
|
190
|
+
let name = "";
|
|
191
|
+
while (i < n && !/[\s/>]/.test(source[i] as string)) {
|
|
192
|
+
name += source[i];
|
|
193
|
+
step();
|
|
194
|
+
}
|
|
195
|
+
const attrs = new Map<string, string>();
|
|
196
|
+
let selfClosing = false;
|
|
197
|
+
while (i < n) {
|
|
198
|
+
while (i < n && /\s/.test(source[i] as string)) step();
|
|
199
|
+
if (i >= n) break;
|
|
200
|
+
if (source[i] === "/" && source[i + 1] === ">") {
|
|
201
|
+
selfClosing = true;
|
|
202
|
+
step();
|
|
203
|
+
step();
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
if (source[i] === ">") {
|
|
207
|
+
step();
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
let attrName = "";
|
|
211
|
+
while (i < n && !/[\s=/>]/.test(source[i] as string)) {
|
|
212
|
+
attrName += source[i];
|
|
213
|
+
step();
|
|
214
|
+
}
|
|
215
|
+
while (i < n && /\s/.test(source[i] as string)) step();
|
|
216
|
+
let attrValue = "";
|
|
217
|
+
if (source[i] === "=") {
|
|
218
|
+
step();
|
|
219
|
+
while (i < n && /\s/.test(source[i] as string)) step();
|
|
220
|
+
const quote = source[i];
|
|
221
|
+
if (quote === '"' || quote === "'") {
|
|
222
|
+
step();
|
|
223
|
+
while (i < n && source[i] !== quote) {
|
|
224
|
+
attrValue += source[i];
|
|
225
|
+
step();
|
|
226
|
+
}
|
|
227
|
+
if (i < n) step();
|
|
228
|
+
} else {
|
|
229
|
+
while (i < n && !/[\s/>]/.test(source[i] as string)) {
|
|
230
|
+
attrValue += source[i];
|
|
231
|
+
step();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (attrName !== "") attrs.set(attrName, attrValue);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const local = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : name;
|
|
239
|
+
if (local === "") continue;
|
|
240
|
+
const node: MutableNode = { name: local, line: tagLine, attrs, children: [] };
|
|
241
|
+
push(node);
|
|
242
|
+
if (!selfClosing) stack.push(node);
|
|
243
|
+
}
|
|
244
|
+
return roots;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Image-presenting widget names — the `android-xml/image-no-label` set. Matched
|
|
248
|
+
* on the bare widget name (`ImageView` / `ImageButton`); AppCompat/Material
|
|
249
|
+
* variants are intentionally NOT image widgets here (they are controls, caught by
|
|
250
|
+
* the control rule via their `clickable`/inherent-control status). */
|
|
251
|
+
const IMAGE_WIDGETS = new Set(["ImageView", "ImageButton"]);
|
|
252
|
+
|
|
253
|
+
/** Inherently-interactive control names — half of the `control-no-name` trigger
|
|
254
|
+
* (the other half is `android:clickable="true"`). `Button`/`ImageButton` are
|
|
255
|
+
* interactive regardless of an explicit `clickable` attribute. */
|
|
256
|
+
const INHERENT_CONTROLS = new Set(["Button", "ImageButton"]);
|
|
257
|
+
|
|
258
|
+
/** True when the element waives Android lint's `ContentDescription` check via its
|
|
259
|
+
* OWN `tools:ignore`. The value is a comma/space-separated id list; `ContentDescription`
|
|
260
|
+
* or `all` silences both rules. */
|
|
261
|
+
function ownSuppressed(attrs: ReadonlyMap<string, string>): boolean {
|
|
262
|
+
const ignore = attrs.get("tools:ignore");
|
|
263
|
+
if (ignore === undefined) return false;
|
|
264
|
+
return ignore
|
|
265
|
+
.split(/[\s,]+/)
|
|
266
|
+
.some((id) => id === "ContentDescription" || id === "all");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** An attribute is present and meaningful when set to a non-empty value. */
|
|
270
|
+
const has = (attrs: ReadonlyMap<string, string>, key: string): boolean => {
|
|
271
|
+
const v = attrs.get(key);
|
|
272
|
+
return v !== undefined && v !== "";
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/** True when an image widget carries a text alternative OR is explicitly marked
|
|
276
|
+
* decorative — either way it must NOT be flagged `image-no-label`. A
|
|
277
|
+
* `contentDescription` (including the `@null` decorative sentinel) or an
|
|
278
|
+
* `importantForAccessibility="no"` opt-out both satisfy 1.1.1. */
|
|
279
|
+
function imageLabeled(attrs: ReadonlyMap<string, string>): boolean {
|
|
280
|
+
if (attrs.has("android:contentDescription")) return true;
|
|
281
|
+
if (attrs.get("android:importantForAccessibility") === "no") return true;
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** True when a control exposes its OWN accessible name — a `contentDescription`,
|
|
286
|
+
* a visible `android:text`, or a `android:hint`. */
|
|
287
|
+
function controlNamedSelf(attrs: ReadonlyMap<string, string>): boolean {
|
|
288
|
+
if (attrs.has("android:contentDescription")) return true;
|
|
289
|
+
if (has(attrs, "android:text")) return true;
|
|
290
|
+
if (has(attrs, "android:hint")) return true;
|
|
291
|
+
// A design-time `tools:text` preview mirrors the text the control is populated
|
|
292
|
+
// with at runtime — so a control carrying one is named, even with no static
|
|
293
|
+
// `android:text` (the dominant pattern for runtime-bound labels/buttons).
|
|
294
|
+
if (has(attrs, "tools:text")) return true;
|
|
295
|
+
if (attrs.get("android:importantForAccessibility") === "no") return true;
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** True when a node is a text-presenting widget — a `TextView` (any variant:
|
|
300
|
+
* `AppCompatTextView`, a vendor `…TextView`, `CheckedTextView`) or an `EditText`.
|
|
301
|
+
* Its presence in a clickable group's subtree means the group is announced by
|
|
302
|
+
* that child's text even when the text is populated at runtime (no static
|
|
303
|
+
* `android:text`), which is the dominant pattern in list-item rows. */
|
|
304
|
+
function isTextWidget(name: string): boolean {
|
|
305
|
+
return name.endsWith("TextView") || name.endsWith("EditText");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** True when ANY element in the subtree (excluding `node` itself) provides text a
|
|
309
|
+
* screen reader would announce for the enclosing clickable group: a non-empty
|
|
310
|
+
* `android:text`, a `contentDescription` that is not the `@null` decorative
|
|
311
|
+
* sentinel, or a text-presenting widget (whose text is often set at runtime).
|
|
312
|
+
* This is the descendant-climb that keeps a labeled child from making its
|
|
313
|
+
* wrapping clickable container read as unnamed. */
|
|
314
|
+
function subtreeProvidesName(node: AndroidNode): boolean {
|
|
315
|
+
for (const child of node.children) {
|
|
316
|
+
if (isTextWidget(child.name)) return true;
|
|
317
|
+
if (has(child.attrs, "android:text")) return true;
|
|
318
|
+
if (has(child.attrs, "tools:text")) return true;
|
|
319
|
+
const cd = child.attrs.get("android:contentDescription");
|
|
320
|
+
if (cd !== undefined && cd !== "" && cd !== "@null") return true;
|
|
321
|
+
if (subtreeProvidesName(child)) return true;
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const isImageWidget = (name: string): boolean => IMAGE_WIDGETS.has(name);
|
|
327
|
+
const isControl = (node: AndroidNode): boolean =>
|
|
328
|
+
INHERENT_CONTROLS.has(node.name) || node.attrs.get("android:clickable") === "true";
|
|
329
|
+
|
|
330
|
+
/** One raw rule hit before it is mapped onto the shared `Finding` shape. */
|
|
331
|
+
interface RawAndroidFinding {
|
|
332
|
+
readonly file: string;
|
|
333
|
+
readonly line: number;
|
|
334
|
+
readonly ruleId: AndroidXmlRuleId;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Per-rule WCAG SC + the human-facing message, keyed by rule id. */
|
|
338
|
+
const RULE_META: Record<AndroidXmlRuleId, { wcag: readonly string[]; message: string }> = {
|
|
339
|
+
"android-xml/image-no-label": {
|
|
340
|
+
wcag: ["1.1.1"],
|
|
341
|
+
message:
|
|
342
|
+
'Image widget has no android:contentDescription — a screen reader announces nothing (set contentDescription, or contentDescription="@null" if purely decorative).',
|
|
343
|
+
},
|
|
344
|
+
"android-xml/control-no-name": {
|
|
345
|
+
wcag: ["4.1.2"],
|
|
346
|
+
message:
|
|
347
|
+
"Interactive control exposes no accessible name — add android:contentDescription (or android:text, or a labeled child) so assistive tech can announce it.",
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Walk one parsed layout tree and apply the two rules, accumulating raw hits.
|
|
353
|
+
* `suppressedAbove` carries an ancestor's `tools:ignore="ContentDescription"`
|
|
354
|
+
* down the subtree, matching Android lint's subtree-scoped suppression. An
|
|
355
|
+
* `ImageButton` that is both an unlabeled image AND an unnamed control emits two
|
|
356
|
+
* findings on the same line — once per rule.
|
|
357
|
+
*/
|
|
358
|
+
function walkTree(
|
|
359
|
+
file: string,
|
|
360
|
+
node: AndroidNode,
|
|
361
|
+
suppressedAbove: boolean,
|
|
362
|
+
out: RawAndroidFinding[],
|
|
363
|
+
): void {
|
|
364
|
+
const suppressed = suppressedAbove || ownSuppressed(node.attrs);
|
|
365
|
+
if (!suppressed) {
|
|
366
|
+
if (isImageWidget(node.name) && !imageLabeled(node.attrs)) {
|
|
367
|
+
out.push({ file, line: node.line, ruleId: "android-xml/image-no-label" });
|
|
368
|
+
}
|
|
369
|
+
if (isControl(node) && !controlNamedSelf(node.attrs) && !subtreeProvidesName(node)) {
|
|
370
|
+
out.push({ file, line: node.line, ruleId: "android-xml/control-no-name" });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
for (const child of node.children) walkTree(file, child, suppressed, out);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Apply the two Android-XML rules to one parsed file's root nodes. `file` is
|
|
378
|
+
* carried through unchanged onto each finding so the caller controls the path
|
|
379
|
+
* namespace.
|
|
380
|
+
*/
|
|
381
|
+
export function findingsForRoots(file: string, roots: readonly AndroidNode[]): RawAndroidFinding[] {
|
|
382
|
+
const out: RawAndroidFinding[] = [];
|
|
383
|
+
for (const root of roots) walkTree(file, root, false, out);
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Parse + rule a single layout source string — the unit-testable seam. */
|
|
388
|
+
export function findingsForSource(file: string, source: string): RawAndroidFinding[] {
|
|
389
|
+
return findingsForRoots(file, parseAndroidLayout(source));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The full output of an Android-XML scan, parallel to `SwiftScanResult`: the
|
|
394
|
+
* findings plus the canonical `root` the collector scanned in (so the CLI renders
|
|
395
|
+
* `relative(root, …)` against the exact namespace the findings carry) and a
|
|
396
|
+
* `parseErrors` count for files that could not be read.
|
|
397
|
+
*/
|
|
398
|
+
export interface AndroidXmlScanResult {
|
|
399
|
+
readonly root: string;
|
|
400
|
+
readonly files: readonly string[];
|
|
401
|
+
readonly findings: readonly Finding[];
|
|
402
|
+
readonly parseErrors: number;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Scan the `layout` resource `.xml` files under `dir` for static Android-XML
|
|
407
|
+
* accessibility findings, in-process. Collects the layout files, parses each,
|
|
408
|
+
* applies the two rules, and maps every raw hit onto a full {@link Finding} —
|
|
409
|
+
* `provenance: "android-xml"`, the rule's WCAG SC, and the enforcement level the
|
|
410
|
+
* governing `binclusive.json` assigns that SC (or `block` with no contract, the
|
|
411
|
+
* historical default). One unreadable file is counted in `parseErrors` and the
|
|
412
|
+
* scan continues — a single bad file never aborts the scan.
|
|
413
|
+
*/
|
|
414
|
+
export async function scanAndroidXml(dir: string): Promise<AndroidXmlScanResult> {
|
|
415
|
+
const root = resolve(dir);
|
|
416
|
+
const files = await collectAndroidLayoutFiles(root);
|
|
417
|
+
|
|
418
|
+
const raw: RawAndroidFinding[] = [];
|
|
419
|
+
let parseErrors = 0;
|
|
420
|
+
for (const file of files) {
|
|
421
|
+
let source: string;
|
|
422
|
+
try {
|
|
423
|
+
source = await readFile(file, "utf8");
|
|
424
|
+
} catch {
|
|
425
|
+
parseErrors++;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
raw.push(...findingsForSource(file, source));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// The contract that governs these files, found by walking up from them — same
|
|
432
|
+
// package-up rule the other collectors use. With no `binclusive.json` every
|
|
433
|
+
// finding is `block`.
|
|
434
|
+
const contract = contractForFiles(raw.map((f) => f.file));
|
|
435
|
+
|
|
436
|
+
const findings: Finding[] = raw.map((f) => ({
|
|
437
|
+
file: f.file,
|
|
438
|
+
line: f.line,
|
|
439
|
+
ruleId: f.ruleId,
|
|
440
|
+
message: RULE_META[f.ruleId].message,
|
|
441
|
+
wcag: RULE_META[f.ruleId].wcag,
|
|
442
|
+
enforcement: enforcementFor(RULE_META[f.ruleId].wcag, contract),
|
|
443
|
+
provenance: "android-xml",
|
|
444
|
+
}));
|
|
445
|
+
|
|
446
|
+
return { root, files, findings, parseErrors };
|
|
447
|
+
}
|
package/src/contract.ts
CHANGED
|
@@ -18,8 +18,13 @@ export const CONTRACT_VERSION = 1 as const;
|
|
|
18
18
|
/** Which Next.js router the repo uses, or `null` when not a Next app. */
|
|
19
19
|
export type Router = "app" | "pages" | null;
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Source language of the repo. `ts`/`js` are decided by tsconfig presence for a
|
|
23
|
+
* web project; `android-xml` is set when the repo is detected as an Android
|
|
24
|
+
* project (a Gradle/manifest layout with `res/layout*` resources), which routes
|
|
25
|
+
* it to the Android XML layout collector instead of the React/TSX path.
|
|
26
|
+
*/
|
|
27
|
+
export type Language = "ts" | "js" | "android-xml";
|
|
23
28
|
|
|
24
29
|
/**
|
|
25
30
|
* The detected stack. Every field is a best-effort signal from
|
|
@@ -134,8 +139,8 @@ function parseRouter(v: unknown): Router {
|
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
function parseLanguage(v: unknown): Language {
|
|
137
|
-
if (v === "ts" || v === "js") return v;
|
|
138
|
-
throw new ContractParseError(`stack.language must be "ts" or "
|
|
142
|
+
if (v === "ts" || v === "js" || v === "android-xml") return v;
|
|
143
|
+
throw new ContractParseError(`stack.language must be "ts", "js", or "android-xml"`);
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
function parseStack(v: unknown): Stack {
|
package/src/core.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "./config-scan";
|
|
23
23
|
import type { Contract } from "./contract";
|
|
24
24
|
import { enforceContent } from "./enforce";
|
|
25
|
+
import { impactFirstJsxA11yMessage } from "./finding-voice";
|
|
25
26
|
import { isRouterLinkControl } from "./registry";
|
|
26
27
|
import {
|
|
27
28
|
type ComponentResolution,
|
|
@@ -75,6 +76,7 @@ export type FindingProvenance =
|
|
|
75
76
|
| "swiftui"
|
|
76
77
|
| "liquid"
|
|
77
78
|
| "unity"
|
|
79
|
+
| "android-xml"
|
|
78
80
|
| "corpus-agent";
|
|
79
81
|
|
|
80
82
|
/**
|
|
@@ -440,7 +442,9 @@ export async function scan(filePaths: readonly string[]): Promise<ScanResult> {
|
|
|
440
442
|
file: result.filePath,
|
|
441
443
|
line: msg.line,
|
|
442
444
|
ruleId: msg.ruleId,
|
|
443
|
-
|
|
445
|
+
// Impact-first voice (#14): rewrite the upstream eslint message to lead
|
|
446
|
+
// with the harmed user. Unmapped rules keep their original message.
|
|
447
|
+
message: impactFirstJsxA11yMessage(msg.ruleId, msg.message),
|
|
444
448
|
wcag,
|
|
445
449
|
enforcement: enforcementFor(wcag, contract),
|
|
446
450
|
provenance: "jsx-a11y",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-sequence collision gate for `.decisions/` (issue #77, ADR 0008).
|
|
3
|
+
*
|
|
4
|
+
* The why: `write-code` allocates the next ADR number at *branch-creation* time
|
|
5
|
+
* with no reservation on the monotonic `.decisions/` sequence, so two
|
|
6
|
+
* decision-builders fanned out in parallel both derive the same next id and
|
|
7
|
+
* collide silently when their branches reach `main` — a duplicate ADR file id
|
|
8
|
+
* and a duplicate row in `.decisions/index.md`. A per-PR review gate cannot see
|
|
9
|
+
* a cross-PR collision on shared global state, so the collision is invisible
|
|
10
|
+
* until both branches merge. This is the *combined-tree* detection/rejection
|
|
11
|
+
* gate ADR 0008 chooses: it runs over the merged `.decisions/` directory and
|
|
12
|
+
* fails loud on any duplicate sequence number (in the files or the index) and
|
|
13
|
+
* on any file<->index drift, so the collision is rejected instead of shipping.
|
|
14
|
+
*
|
|
15
|
+
* Pure and filesystem-narrow: it reads a `.decisions` directory and returns a
|
|
16
|
+
* structured verdict. No process exit, no console — the CLI shell
|
|
17
|
+
* (`scripts/check-decisions.ts`) and the regression test (`test/
|
|
18
|
+
* decisions-collision.test.ts`) both drive this same function.
|
|
19
|
+
*/
|
|
20
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
export interface DecisionsLintResult {
|
|
24
|
+
/** true iff no collision and no file<->index drift was found. */
|
|
25
|
+
ok: boolean;
|
|
26
|
+
/** Human-readable lines, one per problem; empty when ok. */
|
|
27
|
+
errors: string[];
|
|
28
|
+
/** Sorted unique 4-digit ADR ids discovered among the `.decisions/` files. */
|
|
29
|
+
ids: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** An ADR filename is `NNNN-slug.md` with a zero-padded 4-digit sequence. */
|
|
33
|
+
const ADR_FILE_RE = /^(\d{4})-.*\.md$/;
|
|
34
|
+
/** The optional frontmatter `id:` line, used to cross-check the filename. */
|
|
35
|
+
const FRONTMATTER_ID_RE = /^id:\s*(\d{4})\s*$/m;
|
|
36
|
+
|
|
37
|
+
/** Pull the 4-digit ADR id out of an `index.md` table row's FIRST cell only. */
|
|
38
|
+
function indexRowId(line: string): string | null {
|
|
39
|
+
if (!line.trimStart().startsWith("|")) return null;
|
|
40
|
+
const cells = line.split("|");
|
|
41
|
+
// cells[0] is the empty span before the leading pipe; cells[1] is column 1.
|
|
42
|
+
const firstCell = cells[1];
|
|
43
|
+
if (firstCell === undefined) return null;
|
|
44
|
+
// The header cell is `#` and the separator cell is `---`; neither carries a
|
|
45
|
+
// 4-digit run, so matching \d{4} in column 1 selects only real ADR rows.
|
|
46
|
+
const m = firstCell.match(/(\d{4})/);
|
|
47
|
+
return m ? m[1]! : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lint a `.decisions` directory for ADR-sequence collisions and index drift.
|
|
52
|
+
* Returns ok:false with one error line per problem; never throws on a
|
|
53
|
+
* malformed-but-present tree (a missing `index.md` is itself reported).
|
|
54
|
+
*/
|
|
55
|
+
export function lintDecisions(decisionsDir: string): DecisionsLintResult {
|
|
56
|
+
const errors: string[] = [];
|
|
57
|
+
|
|
58
|
+
// 1. Map every ADR file to its sequence id; flag duplicate sequence numbers.
|
|
59
|
+
const filesById = new Map<string, string[]>();
|
|
60
|
+
for (const name of readdirSync(decisionsDir).sort()) {
|
|
61
|
+
const m = name.match(ADR_FILE_RE);
|
|
62
|
+
if (!m) continue;
|
|
63
|
+
const id = m[1]!;
|
|
64
|
+
const list = filesById.get(id) ?? [];
|
|
65
|
+
list.push(name);
|
|
66
|
+
filesById.set(id, list);
|
|
67
|
+
|
|
68
|
+
// Frontmatter `id:` must agree with the filename, so a renumbered-by-hand
|
|
69
|
+
// file whose frontmatter still claims the old id is caught too.
|
|
70
|
+
const fmId = readFileSync(join(decisionsDir, name), "utf8").match(
|
|
71
|
+
FRONTMATTER_ID_RE,
|
|
72
|
+
)?.[1];
|
|
73
|
+
if (fmId !== undefined && fmId !== id) {
|
|
74
|
+
errors.push(
|
|
75
|
+
`ADR file ${name}: frontmatter id '${fmId}' does not match filename sequence '${id}'`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const [id, names] of [...filesById].sort()) {
|
|
80
|
+
if (names.length > 1) {
|
|
81
|
+
errors.push(
|
|
82
|
+
`duplicate ADR sequence number ${id}: ${names.join(", ")} — two decisions collide on the same id (issue #77)`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. Parse `index.md` rows; flag duplicate rows for the same id.
|
|
88
|
+
const indexById = new Map<string, number>();
|
|
89
|
+
let indexPresent = true;
|
|
90
|
+
let indexText: string;
|
|
91
|
+
try {
|
|
92
|
+
indexText = readFileSync(join(decisionsDir, "index.md"), "utf8");
|
|
93
|
+
} catch {
|
|
94
|
+
indexPresent = false;
|
|
95
|
+
indexText = "";
|
|
96
|
+
errors.push(`.decisions/index.md is missing — cannot verify ADR rows`);
|
|
97
|
+
}
|
|
98
|
+
if (indexPresent) {
|
|
99
|
+
for (const line of indexText.split("\n")) {
|
|
100
|
+
const id = indexRowId(line);
|
|
101
|
+
if (id === null) continue;
|
|
102
|
+
indexById.set(id, (indexById.get(id) ?? 0) + 1);
|
|
103
|
+
}
|
|
104
|
+
for (const [id, count] of [...indexById].sort()) {
|
|
105
|
+
if (count > 1) {
|
|
106
|
+
errors.push(
|
|
107
|
+
`duplicate index.md row for ADR ${id} (${count} rows) — two decisions appended the same id (issue #77)`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 3. Cross-check files <-> index 1:1, so a collision that surfaces as a file
|
|
114
|
+
// without a row (or a row without a file) is rejected too.
|
|
115
|
+
if (indexPresent) {
|
|
116
|
+
for (const id of [...filesById.keys()].sort()) {
|
|
117
|
+
if (!indexById.has(id)) {
|
|
118
|
+
errors.push(
|
|
119
|
+
`ADR ${id} has a file but no row in index.md — index out of sync`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const id of [...indexById.keys()].sort()) {
|
|
124
|
+
if (!filesById.has(id)) {
|
|
125
|
+
errors.push(
|
|
126
|
+
`index.md row for ADR ${id} has no matching .decisions/${id}-*.md file — index out of sync`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
ok: errors.length === 0,
|
|
134
|
+
errors,
|
|
135
|
+
ids: [...filesById.keys()].sort(),
|
|
136
|
+
};
|
|
137
|
+
}
|