@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.
@@ -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
- /** Source language of the repo, decided by tsconfig presence. */
22
- export type Language = "ts" | "js";
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 "js"`);
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
- message: msg.message,
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
+ }