@lariocpt/flawless-react 0.0.0-stage → 0.3.1
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/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- 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
|
+
}
|