@lariocpt/flawless-react 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +608 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
@@ -0,0 +1,227 @@
1
+ // The `flawless-react skills` subcommand's grammar, in one pure function.
2
+ //
3
+ // Two consumers need this to agree with the shipped prose, and neither of them
4
+ // runs anything: main() dispatches on it, and `--check` parses the
5
+ // `flawless-react skills install …` lines in the README, the skills and the
6
+ // website so a flag that was renamed cannot survive in documentation. That is
7
+ // also why it lives apart from cli.ts rather than inside it — a subcommand that
8
+ // only main() understands is a subcommand the docs lint cannot see.
9
+ //
10
+ // Without this file, `flawless-react skills install --all` would parse as
11
+ // entry glob = `skills` and then fail on an unknown `--install`… or worse, in a
12
+ // docs lint, pass for the wrong reason.
13
+ import { type FlagDoc, renderBlock } from "../cliFlags.ts";
14
+
15
+ export const SKILLS_SUBCOMMAND = "skills";
16
+
17
+ /** True when argv is a subcommand invocation rather than a generation run. */
18
+ export function isSkillsArgv(argv: readonly string[]): boolean {
19
+ return argv[0] === SKILLS_SUBCOMMAND;
20
+ }
21
+
22
+ export type SkillsVerb = "list" | "install" | "help";
23
+
24
+ export interface SkillsCommand {
25
+ verb: SkillsVerb;
26
+ /** Every skill, no menu. Implied when stdout is not a terminal. */
27
+ all: boolean;
28
+ /** Only these skills. Repeatable AND comma-separated, because it reads both ways. */
29
+ select: string[];
30
+ /** `$HOME`-scoped instead of project-scoped. */
31
+ global: boolean;
32
+ /** Overwrite a canonical file that differs from the embedded copy. */
33
+ force: boolean;
34
+ /** Copy into the compat directory instead of linking it. */
35
+ copy: boolean;
36
+ }
37
+
38
+ /**
39
+ * Flags of the `install` verb, as documentation.
40
+ *
41
+ * `--force` is deliberately NOT repeated here: it is already a run flag with the
42
+ * same word for the same idea (overwrite what a run would otherwise keep), so
43
+ * listing it twice would give the inventory two entries for one spelling.
44
+ */
45
+ export const SKILL_FLAGS: readonly FlagDoc[] = [
46
+ {
47
+ name: "--all",
48
+ summary: "Every skill, no menu.",
49
+ },
50
+ {
51
+ name: "--select",
52
+ value: "<names>",
53
+ summary: ["Only the named skills, comma-separated or", "repeated. Unknown names exit 2."],
54
+ },
55
+ {
56
+ name: "--global",
57
+ alias: "-g",
58
+ summary: [
59
+ "Install into ~/.agents/skills (and link into",
60
+ "~/.claude/skills) instead of the project's.",
61
+ ],
62
+ },
63
+ {
64
+ name: "--copy",
65
+ summary: [
66
+ "Copy into .claude/skills instead of linking.",
67
+ "Automatic when the system refuses symlinks.",
68
+ ],
69
+ },
70
+ ];
71
+
72
+ /**
73
+ * What exists, in display order: the umbrella first, because it is the router.
74
+ *
75
+ * A literal tuple, not derived from the embedded files, so that this module —
76
+ * the grammar — is importable without importing the ~70 KB of markdown. main()'s
77
+ * parse-only surfaces (`--help`, `--flags`, `--check`) and the docs lint pay for
78
+ * the words only; the bytes come in with the runner. data.ts maps these names
79
+ * onto file contents with a Record, so the two cannot drift.
80
+ */
81
+ export const SKILL_CHOICES = [
82
+ "flawless-react",
83
+ "flawless-nextjs",
84
+ "flawless-unit-browser",
85
+ "flawless-perf-harness",
86
+ "flawless-perf-static",
87
+ "flawless-a11y",
88
+ "flawless-react-rules",
89
+ "flawless-findings",
90
+ "flawless-e2e",
91
+ "flawless-load",
92
+ "flawless-api-fixtures",
93
+ "flawless-regen",
94
+ "flawless-api",
95
+ "flawless-expo",
96
+ ] as const;
97
+
98
+ /** The names a `--select` may use. */
99
+ export const SKILL_NAMES: readonly string[] = SKILL_CHOICES;
100
+
101
+ const USAGE = `flawless-react skills <list|install> [options]
102
+
103
+ Agent skills that teach a coding agent to drive flawless-react — the same files
104
+ this repo lints against the CLI, embedded in the binary.
105
+
106
+ skills list list the embedded skills and their status
107
+ skills install on a terminal: pick which ones to install;
108
+ otherwise (piped, CI): install every one
109
+ skills install --all every skill, no menu
110
+ skills install --select a,b only the named skills (repeatable)
111
+ skills install --global target ~/.agents/skills instead of ./.agents/skills
112
+ skills install --force overwrite a SKILL.md you have edited locally
113
+ skills install --copy copy into .claude/skills instead of linking it
114
+
115
+ Each skill is written once, to .agents/skills/<name>/SKILL.md — the cross-client
116
+ location Codex, Cursor and OpenCode read — and linked into
117
+ .claude/skills/<name>, because Claude Code is the one mainstream host that does
118
+ not read the standard path. A real directory there was not written by
119
+ flawless-react, so it is reported and left alone: remove it by hand if you want
120
+ the link. A locally-edited SKILL.md is refused without --force.
121
+
122
+ Options for 'install':
123
+ `;
124
+
125
+ /**
126
+ * The `skills --help` text. `--force` and `--help` are rendered from the same
127
+ * table as the rest, so the left column aligns by the same rule as the main
128
+ * help rather than by a padEnd copied into a second place.
129
+ */
130
+ export function renderSkillsHelp(): string {
131
+ const rows: FlagDoc[] = [
132
+ ...SKILL_FLAGS,
133
+ {
134
+ name: "--force",
135
+ alias: "-f",
136
+ summary: [
137
+ "Overwrite a SKILL.md you have edited locally.",
138
+ "Never deletes a .claude/skills directory.",
139
+ ],
140
+ },
141
+ { name: "--help", alias: "-h", summary: "Show this help." },
142
+ ];
143
+ return `${USAGE}${renderBlock(rows)}`;
144
+ }
145
+
146
+ function splitNames(raw: string): string[] {
147
+ return raw
148
+ .split(",")
149
+ .map((s) => s.trim())
150
+ .filter((s) => s !== "");
151
+ }
152
+
153
+ /**
154
+ * Parse the arguments AFTER the `skills` word (main() strips the subcommand).
155
+ *
156
+ * Throws on anything invalid, with the message naming the offender and the
157
+ * choices, because "bad usage → exit 2" is only useful if it says what was bad.
158
+ */
159
+ export function parseSkillsCommand(argv: readonly string[]): SkillsCommand {
160
+ const first = argv[0];
161
+ if (first === undefined || first === "help" || first === "-h" || first === "--help") {
162
+ return { verb: "help", all: false, select: [], global: false, force: false, copy: false };
163
+ }
164
+ if (first !== "list" && first !== "install") {
165
+ throw new Error(`unknown skills verb: ${first}. Available: list, install`);
166
+ }
167
+ const cmd: SkillsCommand = {
168
+ verb: first,
169
+ all: false,
170
+ select: [],
171
+ global: false,
172
+ force: false,
173
+ copy: false,
174
+ };
175
+
176
+ for (let i = 1; i < argv.length; i++) {
177
+ const a = argv[i] as string;
178
+ if (a === "--all") cmd.all = true;
179
+ else if (a.startsWith("--select=")) cmd.select.push(...splitNames(a.slice("--select=".length)));
180
+ else if (a === "--select") {
181
+ const v = argv[++i];
182
+ if (v === undefined) throw new Error("--select requires skill names");
183
+ cmd.select.push(...splitNames(v));
184
+ } else if (a === "--global" || a === "-g") cmd.global = true;
185
+ else if (a === "--force" || a === "-f") cmd.force = true;
186
+ else if (a === "--copy") cmd.copy = true;
187
+ else if (a === "-h" || a === "--help") {
188
+ cmd.verb = "help";
189
+ break;
190
+ } else if (a.startsWith("-")) {
191
+ throw new Error(`unknown flag for 'skills ${cmd.verb}': ${a}`);
192
+ } else {
193
+ throw new Error(`unexpected argument after 'skills ${cmd.verb}': ${a}`);
194
+ }
195
+ }
196
+
197
+ // `skills list --global` used to be accepted and ignored. A flag that does
198
+ // nothing is worse than a refusal: it reads as if the scope it names had been
199
+ // honoured. `list` needs no scope because it reports BOTH of them as columns, and
200
+ // every install flag is meaningless for it — which is why the check below is
201
+ // "any flag at all", not a hand-maintained subset that a new flag could slip past.
202
+ if (cmd.verb === "list") {
203
+ const ignored = [
204
+ cmd.all && "--all",
205
+ cmd.select.length > 0 && "--select",
206
+ cmd.global && "--global",
207
+ cmd.force && "--force",
208
+ cmd.copy && "--copy",
209
+ ].filter((s): s is string => s !== false);
210
+ if (ignored.length > 0) {
211
+ throw new Error(
212
+ `skills list takes no flags (${ignored.join(", ")}): it reports every embedded skill, with its status in both scopes`,
213
+ );
214
+ }
215
+ return cmd;
216
+ }
217
+
218
+ if (cmd.all && cmd.select.length > 0) {
219
+ throw new Error("--all and --select are mutually exclusive: say which you mean");
220
+ }
221
+
222
+ const unknown = cmd.select.filter((n) => !(SKILL_CHOICES as readonly string[]).includes(n));
223
+ if (unknown.length > 0) {
224
+ throw new Error(`unknown skill: ${unknown.join(", ")}. Available: ${SKILL_CHOICES.join(", ")}`);
225
+ }
226
+ return cmd;
227
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The standalone suite's package.json script names and commands, in one place.
3
+ *
4
+ * Two tasks write `<outDir>/package.json` — perf-harness through
5
+ * scaffold/templates.ts, unit-browser through scaffold-tests/index.ts — and both
6
+ * used to declare `test:perf`. perf-harness said `playwright test`, unit-browser
7
+ * said `playwright test profile`, so which one survived depended on which task
8
+ * ran last. Nothing failed, which is exactly why it lasted: both commands work,
9
+ * and only the bytes differed by task order.
10
+ *
11
+ * Deciding which emitter wins is the wrong axis. The disagreement exists because
12
+ * one intent is spelled twice, so it is spelled here once instead and neither
13
+ * emitter keeps a private copy. Two emitters may both declare a key — they
14
+ * cannot disagree about it — so merge order stops being observable by
15
+ * construction rather than by policy. tests/core.test.ts fails if they ever do
16
+ * diverge again.
17
+ */
18
+ export const SUITE_SCRIPT = {
19
+ test: "test",
20
+ unit: "test:unit",
21
+ perf: "test:perf",
22
+ browser: "test:browser",
23
+ headed: "test:headed",
24
+ report: "report",
25
+ } as const;
26
+
27
+ /**
28
+ * The same actions in Next.js mode, where the TARGET app's package.json holds
29
+ * them and there is no suite package.json at all.
30
+ *
31
+ * A separate map rather than a prefix of SUITE_SCRIPT, deliberately: these run
32
+ * different commands, not the same commands under another name — `flawless:perf`
33
+ * carries an explicit --config and --project that `test:perf` gets from the
34
+ * config it already sits beside. Deriving one from the other would encode a
35
+ * coincidence as a rule. The commands themselves stay in core.ts, which is the
36
+ * only place that knows the out dir they have to be written relative to.
37
+ */
38
+ export const NEXT_SCRIPT = {
39
+ unit: "flawless:unit",
40
+ perf: "flawless:perf",
41
+ browser: "flawless:browser",
42
+ e2e: "flawless:e2e",
43
+ load: "flawless:load",
44
+ report: "flawless:report",
45
+ } as const;
46
+
47
+ /**
48
+ * The vitest config's filename — `.mts`, not `.ts`.
49
+ *
50
+ * Vite decides whether a config is ESM or CJS from the nearest package.json,
51
+ * and in Next.js mode the nearest one is the TARGET APP's, which typically has
52
+ * no `"type": "module"`. The emitted config is ESM, so Vite loaded it as
53
+ * CommonJS and warned that its own strict loader — "planned to become the
54
+ * default in a future major version" — will reject it. Standalone was fine only
55
+ * by accident, because the suite's own package.json sets `"type": "module"`.
56
+ *
57
+ * `.mts` is unambiguous wherever it sits, so the same name works in both modes
58
+ * and nothing has to reason about whose package.json is nearest. Auto-discovery
59
+ * (`vitest run --root <out>`) finds it, and so does an explicit `--config`.
60
+ */
61
+ export const VITEST_CONFIG = "vitest.config.mts";
62
+
63
+ export const SUITE_COMMAND = {
64
+ unit: "vitest run",
65
+ /**
66
+ * No positional filter, deliberately.
67
+ *
68
+ * `playwright test profile` duplicated `testDir: "./profile"` from the very
69
+ * config it runs under, and that duplication has already cost a release: the
70
+ * profile specs moved from `tests/` to `profile/`, the filter did not, and
71
+ * `bun run test:perf` silently selected nothing while --run-tests reported a
72
+ * correct suite as failing (d47abbe). Scope belongs to playwright.config.ts
73
+ * alone, which cannot fall out of step with itself. The browser specs are not
74
+ * swept up by this: they live under `browser/` with their own config.
75
+ */
76
+ perf: "playwright test",
77
+ browser: "playwright test --config browser/playwright.config.ts",
78
+ headed: "playwright test --headed",
79
+ report: "node ./scripts/report.mjs",
80
+ } as const;
81
+
82
+ /**
83
+ * The same actions in Expo mode.
84
+ *
85
+ * A separate map from NEXT_SCRIPT, for the reason NEXT_SCRIPT is separate from
86
+ * SUITE_SCRIPT: these run different commands, not the same commands under
87
+ * another name. The KEY NAMES match NEXT_SCRIPT's where the action is the same,
88
+ * so a project that was Next and became Expo upgrades the key rather than
89
+ * accumulating two namespaces.
90
+ *
91
+ * `perf` and `browser` are deliberately absent: nothing emits their targets yet,
92
+ * and core.ts's whole gating rule is that a script whose target does not exist is
93
+ * a broken promise.
94
+ */
95
+ export const EXPO_SCRIPT = {
96
+ unit: "flawless:unit",
97
+ e2e: "flawless:e2e",
98
+ load: "flawless:load",
99
+ report: "flawless:report",
100
+ } as const;
101
+
102
+ /**
103
+ * The emitted jest config's filename — `.cjs`, not `.js`.
104
+ *
105
+ * Jest loads `jest.config.js` through `require`, which is CJS unless the nearest
106
+ * package.json says `"type": "module"`; in Expo mode the nearest one is the
107
+ * TARGET APP's, whose type we do not control. Exactly the reasoning behind
108
+ * VITEST_CONFIG being `.mts`.
109
+ */
110
+ export const JEST_CONFIG = "jest.config.cjs";
@@ -0,0 +1,281 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ VALID_ACCESSIBILITY_ROLES,
5
+ VALID_ROLES,
6
+ VECTOR_ICON_FAMILIES,
7
+ } from "../analyse/nativeProps.ts";
8
+ import type {
9
+ ComponentInfo,
10
+ ComponentTree,
11
+ Finding,
12
+ NativeA11yElement,
13
+ RunContext,
14
+ TaskResult,
15
+ } from "../types.ts";
16
+ import { findingsByComponent, groupFindingsByFile } from "./biomeSweep.ts";
17
+
18
+ /** The sweep writes ONE aggregate, not one report per component. Declared here
19
+ * and re-exported from a11yScan.ts (its historical home) so the web and native
20
+ * engines cannot disagree about the file the sidecar pointer names. */
21
+ export const A11Y_REPORT = "a11y.json";
22
+
23
+ /**
24
+ * The native accessibility analyser — wave 5 of the Expo mode.
25
+ *
26
+ * Biome cannot be the native a11y engine: all 29 of the web task's rules are
27
+ * HTML/ARIA, and over 31 React Native files carrying 82 unlabelled Pressables
28
+ * they returned ZERO findings while finding 3/3 on the DOM offender fixture —
29
+ * a clean report that meant "these rules do not apply", presented as coverage.
30
+ * This module is the replacement: six rules over the a11y census the analysis
31
+ * retains for native-dialect files (NativeA11yElement), judged at task time the
32
+ * way perf-static judges retained ComponentInfo.
33
+ *
34
+ * Two structural properties matter more than any single rule:
35
+ *
36
+ * - THE DENOMINATOR. The summary and the aggregate always carry how many
37
+ * press targets / text inputs / icons were examined and how many rules ran,
38
+ * so "0 findings" can never again be confused with "the rules do not apply".
39
+ * - ZERO FALSE POSITIVES over completeness. Every rule stands down where the
40
+ * source is not readable (a dynamic accessibilityLabel, a spread) — a miss
41
+ * costs coverage, a guess costs a false finding. The declined rules and the
42
+ * evidence live in docs/archive/backlog-completed-2026-10.md's B-16 row.
43
+ */
44
+ export const NATIVE_A11Y_RULES = [
45
+ "a11y/unnamed-control",
46
+ "a11y/missing-role",
47
+ "a11y/textinput-unlabelled",
48
+ "a11y/icon-unhidden",
49
+ "a11y/label-text-mismatch",
50
+ "a11y/invalid-role",
51
+ ] as const;
52
+
53
+ export interface NativeA11yStats {
54
+ /** Unique files behind native-dialect components — the filesScanned denominator. */
55
+ nativeFiles: string[];
56
+ /** Unique files behind dom-dialect components: counted and NAMED, never silently skipped. */
57
+ domDialectFiles: string[];
58
+ pressTargets: number;
59
+ textInputs: number;
60
+ icons: number;
61
+ }
62
+
63
+ const ICON_FAMILIES: ReadonlySet<string> = new Set(VECTOR_ICON_FAMILIES);
64
+
65
+ /**
66
+ * Pure rule evaluation over the retained census, exported for the tests: the
67
+ * mutation probes re-analyse a fixture and call this directly, so they stay
68
+ * fast and ungated while still exercising the exact production path.
69
+ */
70
+ export function nativeA11yFindings(trees: ComponentTree[]): {
71
+ findings: Finding[];
72
+ stats: NativeA11yStats;
73
+ } {
74
+ const infos = collectComponents(trees);
75
+
76
+ const nativeFiles = new Set<string>();
77
+ const domFiles = new Set<string>();
78
+ // Every component analysed out of a file carries the file's whole census
79
+ // (the walk covers same-file siblings on purpose), so elements dedupe by
80
+ // their own position before any counting or judging happens.
81
+ const elements = new Map<string, NativeA11yElement>();
82
+ for (const info of infos) {
83
+ if (info.hostDialect === "native") {
84
+ nativeFiles.add(path.resolve(info.filePath));
85
+ for (const e of info.interactions?.a11yElements ?? []) {
86
+ const key = `${e.pos.file}:${e.pos.line}:${e.pos.column}`;
87
+ if (!elements.has(key)) elements.set(key, e);
88
+ }
89
+ } else {
90
+ domFiles.add(path.resolve(info.filePath));
91
+ }
92
+ }
93
+
94
+ const stats: NativeA11yStats = {
95
+ nativeFiles: [...nativeFiles].sort(),
96
+ domDialectFiles: [...domFiles].sort(),
97
+ pressTargets: 0,
98
+ textInputs: 0,
99
+ icons: 0,
100
+ };
101
+ const findings: Finding[] = [];
102
+ for (const e of elements.values()) {
103
+ if (e.pressable) stats.pressTargets += 1;
104
+ if (e.element === "TextInput") stats.textInputs += 1;
105
+ if (ICON_FAMILIES.has(e.element)) stats.icons += 1;
106
+ judge(e, findings);
107
+ }
108
+ findings.sort(
109
+ (a, b) =>
110
+ a.file.localeCompare(b.file) ||
111
+ a.line - b.line ||
112
+ a.column - b.column ||
113
+ a.rule.localeCompare(b.rule),
114
+ );
115
+ return { findings, stats };
116
+ }
117
+
118
+ function judge(e: NativeA11yElement, out: Finding[]): void {
119
+ const finding = (rule: string, severity: "error" | "warn", message: string): void => {
120
+ out.push({
121
+ rule,
122
+ severity,
123
+ file: e.pos.file,
124
+ line: e.pos.line,
125
+ column: e.pos.column,
126
+ message,
127
+ source: "a11y",
128
+ });
129
+ };
130
+
131
+ // Only the PROVABLY nameless case: children that are all textless (the
132
+ // iconOnly proof) or absent. A pressable rendering `{building.name}` DOES
133
+ // announce at runtime, so label-absence there is not a finding — that
134
+ // restraint is what keeps this rule at zero false positives.
135
+ if (e.pressable && e.textless && !e.hints.ariaLabel && !e.dynamicLabel) {
136
+ finding(
137
+ "a11y/unnamed-control",
138
+ "error",
139
+ `<${e.element}> renders no text and carries no accessibilityLabel — it is silent ` +
140
+ `to a screen reader and unaddressable to a spec. One accessibilityLabel retires both.`,
141
+ );
142
+ }
143
+
144
+ // RN has no implicit roles: RNTL's getRole() reads the explicit prop, else
145
+ // "text" for Text, else "none" — and the platforms announce accordingly. RN's
146
+ // own <Button> is exempt for free: collectHints injects its one implicit role.
147
+ if (e.pressable && e.hints.role === undefined && !e.dynamicRole) {
148
+ finding(
149
+ "a11y/missing-role",
150
+ "error",
151
+ `<${e.element}> has no accessibilityRole — TalkBack and VoiceOver announce its ` +
152
+ `content without saying it can be activated. Add accessibilityRole="button" ` +
153
+ `(or the role it actually plays).`,
154
+ );
155
+ }
156
+
157
+ if (e.element === "TextInput" && !e.hints.ariaLabel && !e.dynamicLabel) {
158
+ finding(
159
+ "a11y/textinput-unlabelled",
160
+ "error",
161
+ e.hints.placeholder === undefined
162
+ ? `<TextInput> has no accessibilityLabel and no placeholder — the field is announced as nothing at all.`
163
+ : `<TextInput placeholder="${e.hints.placeholder}"> has no accessibilityLabel — a ` +
164
+ `placeholder is not a label: it disappears on input and is not the field's accessible name.`,
165
+ );
166
+ }
167
+
168
+ if (ICON_FAMILIES.has(e.element) && !e.hiddenFromA11y && !e.exemptAncestor) {
169
+ finding(
170
+ "a11y/icon-unhidden",
171
+ "warn",
172
+ `<${e.element}> is exposed to the accessibility tree with nothing to announce — a ` +
173
+ `vector icon renders a private-use glyph. Add aria-hidden, or move it inside the ` +
174
+ `labelled control it decorates.`,
175
+ );
176
+ }
177
+
178
+ // WCAG 2.5.3 Label in Name: voice-control users say what they SEE, and an
179
+ // accessibilityLabel that does not contain the visible text makes the visible
180
+ // words a command that activates nothing.
181
+ if (
182
+ e.hints.ariaLabel !== undefined &&
183
+ (e.label.kind === "text" || e.label.kind === "text-partial") &&
184
+ !e.hints.ariaLabel.toLowerCase().includes(e.label.value.toLowerCase())
185
+ ) {
186
+ finding(
187
+ "a11y/label-text-mismatch",
188
+ "error",
189
+ `accessibilityLabel "${e.hints.ariaLabel}" does not contain the visible text ` +
190
+ `"${e.label.value}" — voice-control users say what they see, and saying it here ` +
191
+ `activates nothing (WCAG 2.5.3).`,
192
+ );
193
+ }
194
+
195
+ for (const { attr, value } of e.roleLiterals ?? []) {
196
+ const valid = attr === "role" ? VALID_ROLES : VALID_ACCESSIBILITY_ROLES;
197
+ if (!valid.has(value)) {
198
+ finding(
199
+ "a11y/invalid-role",
200
+ "error",
201
+ `${attr}="${value}" is not a value React Native recognises, so the platform ` +
202
+ `announces no role at all — and the union widens with \`| string\`, so tsc ` +
203
+ `cannot catch it either.`,
204
+ );
205
+ }
206
+ }
207
+ }
208
+
209
+ /** The native engine behind the `a11y` task under --expo. */
210
+ export async function runNativeA11y(ctx: RunContext): Promise<TaskResult> {
211
+ const reportsDir = path.join(ctx.outDir, "reports");
212
+ await mkdir(reportsDir, { recursive: true });
213
+
214
+ const { findings, stats } = nativeA11yFindings(ctx.trees);
215
+ const byFile = groupFindingsByFile(findings);
216
+ const written: string[] = [];
217
+
218
+ const aggregatePath = path.join(reportsDir, A11Y_REPORT);
219
+ const aggregate = {
220
+ task: "a11y",
221
+ engine: "native",
222
+ filesScanned: stats.nativeFiles.length,
223
+ findingCount: findings.length,
224
+ // The denominator that makes 0 findings distinguishable from "the rules do
225
+ // not apply" — the vacuous green this wave exists to kill.
226
+ elementsExamined: {
227
+ pressTargets: stats.pressTargets,
228
+ textInputs: stats.textInputs,
229
+ icons: stats.icons,
230
+ },
231
+ rulesApplied: [...NATIVE_A11Y_RULES],
232
+ ...(stats.domDialectFiles.length > 0 ? { domDialectFilesNotSwept: stats.domDialectFiles } : {}),
233
+ findingsByFile: Object.fromEntries(byFile),
234
+ };
235
+ await Bun.write(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`);
236
+ written.push(aggregatePath);
237
+
238
+ for (const owner of findingsByComponent(ctx.trees, byFile)) {
239
+ const out = path.join(reportsDir, `${owner.tree.entry.componentName}.a11y.json`);
240
+ const report = {
241
+ component: owner.tree.entry.componentName,
242
+ file: owner.tree.entry.filePath,
243
+ findings: owner.findings,
244
+ };
245
+ await Bun.write(out, `${JSON.stringify(report, null, 2)}\n`);
246
+ written.push(out);
247
+ }
248
+
249
+ const denominator =
250
+ `${stats.pressTargets} press target(s), ${stats.textInputs} text input(s), ` +
251
+ `${stats.icons} icon(s) examined, ${NATIVE_A11Y_RULES.length} native rules`;
252
+ const domNote =
253
+ stats.domDialectFiles.length > 0
254
+ ? `; ${stats.domDialectFiles.length} dom-dialect file(s) NOT swept — run without --expo for those`
255
+ : "";
256
+ return {
257
+ id: "a11y",
258
+ ok: true,
259
+ outputs: written,
260
+ findings,
261
+ summary: `a11y: ${findings.length} finding(s) across ${stats.nativeFiles.length} file(s) — ${denominator}${domNote}`,
262
+ };
263
+ }
264
+
265
+ function collectComponents(trees: ComponentTree[]): ComponentInfo[] {
266
+ const infos: ComponentInfo[] = [];
267
+ const visited = new Set<ComponentTree>();
268
+ const seen = new Set<string>();
269
+ const collect = (t: ComponentTree): void => {
270
+ if (visited.has(t)) return;
271
+ visited.add(t);
272
+ const key = `${path.resolve(t.entry.filePath)}::${t.entry.componentName}`;
273
+ if (!seen.has(key)) {
274
+ seen.add(key);
275
+ infos.push(t.entry);
276
+ }
277
+ for (const child of t.children) collect(child);
278
+ };
279
+ for (const tree of trees) collect(tree);
280
+ return infos;
281
+ }
@@ -0,0 +1,67 @@
1
+ import type { RunContext, Task, TaskResult } from "../types.ts";
2
+ import { A11Y_REPORT, runNativeA11y } from "./a11yNative.ts";
3
+ import { runBiomeSweep } from "./biomeSweep.ts";
4
+
5
+ /** The sweep writes ONE aggregate, not one report per component. Exported so
6
+ * that anything pointing a reader at it cannot name a file that is never
7
+ * written — which is exactly what the inline sidecar used to do. Declared in
8
+ * a11yNative.ts (both engines write it); re-exported here, its historical
9
+ * import path. */
10
+ export { A11Y_REPORT } from "./a11yNative.ts";
11
+
12
+ export const a11yScan: Task = {
13
+ id: "a11y",
14
+ label: "Accessibility / web-standards scan",
15
+ description: "Biome a11y rules over the targeted tree (file:line); native analyser under --expo",
16
+ needsTree: true,
17
+ async run(ctx: RunContext): Promise<TaskResult> {
18
+ // The wave-3 shape: one TaskId, a sibling engine per platform. Biome cannot
19
+ // be the native engine — all 29 rules below are HTML/ARIA, and over a React
20
+ // Native tree they return zero while the offender fixture scores 3/3.
21
+ if (ctx.expo) return runNativeA11y(ctx);
22
+ return runBiomeSweep({
23
+ id: "a11y",
24
+ ctx,
25
+ rules: biomeA11yRules(),
26
+ categoryPrefix: "lint/a11y",
27
+ aggregateFile: A11Y_REPORT,
28
+ });
29
+ },
30
+ };
31
+
32
+ function biomeA11yRules(): unknown {
33
+ return {
34
+ recommended: false,
35
+ a11y: {
36
+ noAccessKey: "error",
37
+ noAriaUnsupportedElements: "error",
38
+ noAutofocus: "warn",
39
+ noDistractingElements: "error",
40
+ noHeaderScope: "error",
41
+ noInteractiveElementToNoninteractiveRole: "error",
42
+ noLabelWithoutControl: "warn",
43
+ noNoninteractiveElementToInteractiveRole: "error",
44
+ noNoninteractiveTabindex: "warn",
45
+ noPositiveTabindex: "warn",
46
+ noRedundantAlt: "warn",
47
+ noRedundantRoles: "error",
48
+ useAltText: "error",
49
+ useAnchorContent: "error",
50
+ useAriaActivedescendantWithTabindex: "error",
51
+ useAriaPropsForRole: "error",
52
+ useButtonType: "error",
53
+ useFocusableInteractive: "warn",
54
+ useHeadingContent: "error",
55
+ useHtmlLang: "error",
56
+ useIframeTitle: "error",
57
+ useKeyWithClickEvents: "warn",
58
+ useKeyWithMouseEvents: "warn",
59
+ useMediaCaption: "warn",
60
+ useValidAnchor: "warn",
61
+ useValidAriaProps: "error",
62
+ useValidAriaRole: "error",
63
+ useValidAriaValues: "error",
64
+ useValidLang: "error",
65
+ },
66
+ };
67
+ }