@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
package/src/docLine.ts ADDED
@@ -0,0 +1,122 @@
1
+ // Turning a line of documentation into an argv.
2
+ //
3
+ // A documented example is not a bare argv: it carries a runner (`bunx`), env
4
+ // prefixes (`FLAWLESS_E2E_ID=…`), shell quoting (`"src/**/*.tsx"`), and prose
5
+ // after a `#`. Every one of those has to come off before parseArgs can have an
6
+ // opinion, and the same peeling has to happen identically in three places —
7
+ // `--check`, tests/docsLint.test.ts, and the website's lint script — or the
8
+ // three disagree about what was even checked.
9
+ //
10
+ // Deliberately importless. `--check` and `--flags` must answer without pulling
11
+ // in core.ts and the TypeScript compiler behind it (FR-031).
12
+
13
+ /** Runner words a documented invocation may be prefixed with. */
14
+ const RUNNERS = new Set(["bun", "bunx", "npx", "npm", "pnpm", "yarn", "sudo", "time", "env"]);
15
+
16
+ /** How this CLI is spelled when a doc invokes it. */
17
+ const SELF_ENTRIES = new Set(["src/cli.ts", "src/bin.ts"]);
18
+
19
+ const ENV_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*=/;
20
+
21
+ /**
22
+ * Split a command line into argv, honouring quotes and dropping them.
23
+ *
24
+ * `--props='{"a":1}'` has to arrive at parseArgs as `--props={"a":1}`, quotes
25
+ * gone, or parsePropsJson rejects JSON that is perfectly valid in the doc.
26
+ *
27
+ * Throws on an unterminated quote: by the time a line reaches here the caller
28
+ * has already joined continuations, so an open quote is a real defect in the
29
+ * example rather than an artefact of how it was extracted.
30
+ */
31
+ export function shellSplit(line: string): string[] {
32
+ const out: string[] = [];
33
+ let cur = "";
34
+ let started = false;
35
+ let quote: '"' | "'" | null = null;
36
+ for (let i = 0; i < line.length; i++) {
37
+ const c = line[i] as string;
38
+ if (quote !== null) {
39
+ if (c === quote) {
40
+ quote = null;
41
+ continue;
42
+ }
43
+ if (quote === '"' && c === "\\" && i + 1 < line.length) {
44
+ cur += line[++i] as string;
45
+ continue;
46
+ }
47
+ cur += c;
48
+ continue;
49
+ }
50
+ if (c === '"' || c === "'") {
51
+ quote = c;
52
+ started = true;
53
+ continue;
54
+ }
55
+ if (c === " " || c === "\t") {
56
+ if (started) {
57
+ out.push(cur);
58
+ cur = "";
59
+ started = false;
60
+ }
61
+ continue;
62
+ }
63
+ started = true;
64
+ cur += c;
65
+ }
66
+ if (quote !== null) throw new Error(`unterminated ${quote} quote`);
67
+ if (started) out.push(cur);
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Drop an UNQUOTED trailing `#` comment.
73
+ *
74
+ * A `#` inside quotes is data — `--props '{"tag":"#a"}'` — so the scan has to
75
+ * track quoting rather than indexOf("#").
76
+ */
77
+ export function stripTrailingComment(line: string): string {
78
+ let quote: '"' | "'" | null = null;
79
+ for (let i = 0; i < line.length; i++) {
80
+ const c = line[i] as string;
81
+ if (quote !== null) {
82
+ if (quote === '"' && c === "\\") i++;
83
+ else if (c === quote) quote = null;
84
+ continue;
85
+ }
86
+ if (c === '"' || c === "'") quote = c;
87
+ else if (c === "#") return line.slice(0, i);
88
+ }
89
+ return line;
90
+ }
91
+
92
+ /** Is this token the way a doc spells this CLI? */
93
+ function isSelf(token: string): boolean {
94
+ const base = token.split("/").pop() ?? token;
95
+ if (base === "flawless-react") return true;
96
+ return SELF_ENTRIES.has(token) || SELF_ENTRIES.has(token.replace(/^\.\//, ""));
97
+ }
98
+
99
+ /**
100
+ * The argv of a documented flawless-react invocation, or null when the line
101
+ * invokes something else.
102
+ *
103
+ * Returning null rather than failing is the whole point: docs are full of
104
+ * `bun run test`, `playwright test --grep …` and crust pipes, and a lint that
105
+ * treated those as flawless invocations would report failures that are not
106
+ * about this tool. The cost is that a caller must assert it checked a MINIMUM
107
+ * number of lines — silence here is indistinguishable from a broken extractor.
108
+ */
109
+ export function flawlessArgv(line: string): string[] | null {
110
+ const tokens = shellSplit(line);
111
+ let i = 0;
112
+ while (i < tokens.length && ENV_PREFIX.test(tokens[i] as string)) i++;
113
+ while (i < tokens.length && RUNNERS.has(tokens[i] as string)) {
114
+ i++;
115
+ // `bun run <script>` / `npm run <script>`: the script name is not this CLI
116
+ // under any spelling we can resolve, so let the isSelf check below reject.
117
+ if (tokens[i] === "run") i++;
118
+ }
119
+ const cmd = tokens[i];
120
+ if (cmd === undefined || !isSelf(cmd)) return null;
121
+ return tokens.slice(i + 1);
122
+ }
@@ -0,0 +1,51 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * A path for an emitted file to resolve against itself, rather than the
5
+ * absolute one the generator happened to be looking at.
6
+ *
7
+ * Generated files are committed. An absolute path in one names the machine that
8
+ * produced it, and the same repository lives under a different root on other
9
+ * machines — `/home/lario/Projects/…` on two of them, `/mnt/data/Projects/…` on
10
+ * a third. The consequences are worse than churn: `vitest.config.ts` aliased
11
+ * React to a directory that does not exist there, so the committed suite could
12
+ * not resolve React at all, and regenerating rewrote three files every time.
13
+ *
14
+ * The invariant is about the file's BYTES, not its runtime value. Emitted code
15
+ * still calls `path.resolve(<self>, rel)` and still ends up with an absolute
16
+ * path, because that is what Vite's aliases and Bun's onLoad filter need. Only
17
+ * the question "absolute from where" changes: from the emitted file, not from
18
+ * the machine.
19
+ *
20
+ * Always POSIX-separated, so a suite generated on Windows reads the same as one
21
+ * generated anywhere else.
22
+ */
23
+ export function relativeToEmitted(fromDir: string, target: string): string {
24
+ const rel = path.relative(path.resolve(fromDir), path.resolve(target));
25
+ // "" means the target IS the directory; "." is what path.resolve wants back.
26
+ if (rel === "") return ".";
27
+ return rel.split(path.sep).join("/");
28
+ }
29
+
30
+ /**
31
+ * The out dir as the project root sees it — `flawless`, or whatever `--out` said.
32
+ *
33
+ * Emitted prose and emitted shell both name this path, and both used to spell it
34
+ * as the literal "flawless". Under `--out qa` that produced files telling the
35
+ * user to edit a directory the run never created, and an env.sh whose $PWD
36
+ * fallback resolved to nothing. Shared rather than redefined per task: two
37
+ * copies of a path rule drifting apart is its own class of bug here.
38
+ */
39
+ export function outDirRel(outDir: string, projectRoot: string): string {
40
+ const rel = path.relative(path.resolve(projectRoot), path.resolve(outDir));
41
+ // "" means the out dir IS the project root. Left empty it composes into
42
+ // "/load/env.sh", which reads as an absolute path and points at the
43
+ // filesystem root — so "." here, and underOutDir drops it again.
44
+ if (rel === "") return ".";
45
+ return rel.split(path.sep).join("/");
46
+ }
47
+
48
+ /** Compose a path under the out dir, without a "./" nobody wants to read. */
49
+ export function underOutDir(rel: string, sub: string): string {
50
+ return rel === "." || rel === "" ? sub : `${rel}/${sub}`;
51
+ }
@@ -0,0 +1,184 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { detectReactCompiler } from "../reactCompiler.ts";
4
+ import { type ExpoContext, UsageError } from "../types.ts";
5
+
6
+ /**
7
+ * Read an Expo project off disk, the way `detectNextjs` reads a Next.js one.
8
+ *
9
+ * Deliberately the same shape: probe for the framework, throw a UsageError
10
+ * naming the fix when it is not there, and return a context object carrying the
11
+ * facts downstream needs — so a task branches once on `ctx.expo` instead of
12
+ * re-reading app.json.
13
+ *
14
+ * Everything here is filesystem-only. Nothing is inferred from a component, and
15
+ * nothing is defaulted where a wrong value would be worse than an absent one
16
+ * (see `sdkMajor`).
17
+ */
18
+ export async function detectExpo(root: string): Promise<ExpoContext> {
19
+ const abs = path.resolve(root);
20
+ if (!existsSync(abs)) {
21
+ throw new UsageError(`expo: directory not found: ${abs}`);
22
+ }
23
+ if (!looksLikeExpo(abs)) {
24
+ throw new UsageError(
25
+ `expo: ${abs} does not look like an Expo project ` +
26
+ `(no app.json/app.config with an "expo" key, and no "expo" in package.json).`,
27
+ );
28
+ }
29
+
30
+ const pkg = readJson(path.join(abs, "package.json"));
31
+ const deps = {
32
+ ...((pkg?.dependencies as Record<string, string>) ?? {}),
33
+ ...((pkg?.devDependencies as Record<string, string>) ?? {}),
34
+ };
35
+
36
+ const routerRoot = detectRouterRoot(abs, deps);
37
+ // One probe for every mode (src/reactCompiler.ts), so the web and expo answers
38
+ // cannot drift and `perf-static` reads one fact. The boolean is all ExpoContext
39
+ // has ever carried; the evidence beside it reaches tasks on RunContext.
40
+ return {
41
+ root: abs,
42
+ routerRoot,
43
+ sdkMajor: majorOf(deps.expo, path.join(abs, "node_modules", "expo", "package.json")),
44
+ routerMajor: majorOf(
45
+ deps["expo-router"],
46
+ path.join(abs, "node_modules", "expo-router", "package.json"),
47
+ ),
48
+ reactCompiler: detectReactCompiler(abs).state === "on",
49
+ nativewind: detectNativewind(abs),
50
+ webPort: expoWebPort(pkg?.scripts as Record<string, string> | undefined),
51
+ };
52
+ }
53
+
54
+ function looksLikeExpo(dir: string): boolean {
55
+ for (const f of ["app.json", "app.config.js", "app.config.ts", "app.config.mjs"]) {
56
+ const p = path.join(dir, f);
57
+ if (!existsSync(p)) continue;
58
+ // A config file alone is not proof — app.json is a generic name. The `expo`
59
+ // key is, and for the .js/.ts forms (which we will not execute) the string
60
+ // is the closest honest signal.
61
+ if (f === "app.json") {
62
+ if (readJson(p)?.expo !== undefined) return true;
63
+ continue;
64
+ }
65
+ try {
66
+ if (/\bexpo\b/.test(readFileSync(p, "utf8"))) return true;
67
+ } catch {
68
+ /* unreadable config proves nothing either way */
69
+ }
70
+ }
71
+ const pkg = readJson(path.join(dir, "package.json"));
72
+ if (!pkg) return false;
73
+ const deps = {
74
+ ...((pkg.dependencies as Record<string, string>) ?? {}),
75
+ ...((pkg.devDependencies as Record<string, string>) ?? {}),
76
+ };
77
+ return deps.expo !== undefined;
78
+ }
79
+
80
+ /**
81
+ * The expo-router app directory, or undefined for a project that routes some
82
+ * other way (react-navigation by hand, a single-screen app).
83
+ *
84
+ * `app/` alone is not enough: the directory name is generic and a Next.js
85
+ * project has one too. expo-router has to actually be a dependency.
86
+ */
87
+ function detectRouterRoot(dir: string, deps: Record<string, string>): string | undefined {
88
+ if (deps["expo-router"] === undefined) return undefined;
89
+ for (const rel of ["app", "src/app"]) {
90
+ const candidate = path.join(dir, rel);
91
+ if (isDir(candidate)) return candidate;
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /** NativeWind is wired through babel — the only route by which `className` works. */
97
+ function detectNativewind(dir: string): boolean {
98
+ return configMentions(
99
+ dir,
100
+ ["babel.config.js", "babel.config.cjs", "babel.config.mjs"],
101
+ ["nativewind"],
102
+ );
103
+ }
104
+
105
+ function configMentions(dir: string, files: string[], needles: string[]): boolean {
106
+ for (const f of files) {
107
+ const p = path.join(dir, f);
108
+ if (!existsSync(p)) continue;
109
+ try {
110
+ const text = readFileSync(p, "utf8");
111
+ if (needles.some((n) => text.includes(n))) return true;
112
+ } catch {
113
+ /* unreadable config proves nothing */
114
+ }
115
+ }
116
+ return false;
117
+ }
118
+
119
+ /**
120
+ * The port `expo start --web` serves on.
121
+ *
122
+ * A sibling of `devScriptPort`, not a parameterisation of it: the script key
123
+ * differs (`web`, then `start` — never `dev`) AND the default differs (Metro's
124
+ * 8081, not 3000). One function with two key lists and two defaults would encode
125
+ * a coincidence as a rule, which is the mistake `NEXT_SCRIPT`'s own comment
126
+ * warns about. Only the flag regex is genuinely shared, and it is one line.
127
+ */
128
+ export function expoWebPort(scripts: Record<string, string> | undefined): number {
129
+ for (const key of ["web", "start"]) {
130
+ const script = scripts?.[key];
131
+ if (script === undefined) continue;
132
+ const m = /(?:-p|--port)[=\s]*(\d+)/.exec(script);
133
+ if (m?.[1]) {
134
+ const port = Number(m[1]);
135
+ if (Number.isFinite(port)) return port;
136
+ }
137
+ }
138
+ return 8081;
139
+ }
140
+
141
+ /**
142
+ * The major version of a dependency, from the project's declared range and
143
+ * failing that from what is actually installed.
144
+ *
145
+ * Installed truth beats a declared range on purpose: `~54.0.33` and an installed
146
+ * 54.0.34 agree, but `latest`, `*` and a git or file: specifier name no version
147
+ * at all, and node_modules does. Returns undefined when neither answers —
148
+ * because jest-expo's major has to MATCH the SDK's, and a guessed 54 against an
149
+ * SDK 53 project fails deep inside expo-modules-core with a message that names
150
+ * nothing. The caller says "unknown"; it never substitutes a number.
151
+ */
152
+ export function majorOf(range: string | undefined, installedPkgJson: string): number | undefined {
153
+ const fromRange = range === undefined ? undefined : majorOfRange(range);
154
+ if (fromRange !== undefined) return fromRange;
155
+ const installed = readJson(installedPkgJson)?.version;
156
+ return typeof installed === "string" ? majorOfRange(installed) : undefined;
157
+ }
158
+
159
+ function majorOfRange(range: string): number | undefined {
160
+ // The leading digits of the first version-looking token: covers "54.0.33",
161
+ // "~54.0.33", "^54", ">=54.0.0 <55", and "54.x". Not "latest", "*", "next",
162
+ // a git url or a file: path — all of which have no major to report.
163
+ const m = /(?:^|[^\w.])(\d+)\.(?:\d+|x)/.exec(range) ?? /^\s*[~^>=<\s]*(\d+)\s*$/.exec(range);
164
+ if (!m?.[1]) return undefined;
165
+ const n = Number(m[1]);
166
+ return Number.isFinite(n) ? n : undefined;
167
+ }
168
+
169
+ function readJson(p: string): Record<string, unknown> | undefined {
170
+ if (!existsSync(p)) return undefined;
171
+ try {
172
+ return JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ function isDir(p: string): boolean {
179
+ try {
180
+ return statSync(p).isDirectory();
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
@@ -0,0 +1,62 @@
1
+ import type { TaskId, TaskResult } from "../types.ts";
2
+
3
+ /**
4
+ * A task that does not run under `--expo`, refusing by name with its reason.
5
+ *
6
+ * This carried the mode through B-16 one wave at a time without the docs lying
7
+ * about it, and now that B-16 is complete it carries the one task that will
8
+ * never run: `perf-harness`, measured-and-declined (a browser host cannot mount
9
+ * a route screen, which is nearly every component in a real Expo app). The
10
+ * `reason` is free text — a wave string while a wave was pending, a decline
11
+ * record now — so the message never claims "not yet" over a task that is
12
+ * actually declined for good. The alternative shapes were both worse:
13
+ *
14
+ * - Rejecting `--expo` at parse time until every task is ported would mean the
15
+ * flag lands in one enormous PR, which is how a seam stops being reviewable.
16
+ * - Silently emitting the DOM artefacts for a React Native project would
17
+ * produce a suite that runs, passes, and tests nothing — the vacuous green
18
+ * this project scores itself on removing.
19
+ *
20
+ * A refusal is a TaskResult rather than a thrown error on purpose: it lands in
21
+ * `results[]`, in the `--ci` blob and in REPORT.md, where a machine can read it.
22
+ * A thrown error would exit 1 with the reason only on stderr, which is where
23
+ * B-13 already found `--ci` losing things it needed to say.
24
+ *
25
+ * Same shape as `e2eRoutes.ts`'s long-standing "not a Next.js run" branch — the
26
+ * precedent, not a new convention.
27
+ */
28
+ /**
29
+ * The tasks that actually run under `--expo`, as the escape hatch names them.
30
+ *
31
+ * A literal `perf-static,react-rules` was correct when five tasks refused, and
32
+ * wrong from wave 3 onward — `unit-browser` is the whole point of the mode and
33
+ * the refusal was telling every reader to leave it out. Kept beside the refusal
34
+ * so the two cannot disagree, and pinned by a test that runs each of these under
35
+ * `--expo` and asserts it does NOT refuse.
36
+ */
37
+ export const EXPO_IMPLEMENTED: readonly TaskId[] = [
38
+ "unit-browser",
39
+ "perf-static",
40
+ "a11y",
41
+ "react-rules",
42
+ "e2e",
43
+ "load",
44
+ ];
45
+
46
+ export function expoNotImplemented(id: TaskId, reason: string): TaskResult {
47
+ return {
48
+ id,
49
+ ok: false,
50
+ outputs: [],
51
+ findings: [],
52
+ summary: `${id}: does not run under --expo (${reason}) — run --tasks ${EXPO_IMPLEMENTED.join(",")}`,
53
+ // The escape is spelled as the flag the reader would actually type, not as
54
+ // prose naming ids. Told only that something is unsupported, they have to go
55
+ // and read the source to find out what to select instead.
56
+ error:
57
+ `${id} does not run under --expo — ${reason}. ` +
58
+ `Under --expo, run \`--tasks ${EXPO_IMPLEMENTED.join(",")}\`; every other task ` +
59
+ `refuses rather than emit DOM artefacts for a React Native project. ` +
60
+ `See the Expo section of README.md.`,
61
+ };
62
+ }
@@ -0,0 +1,145 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * Map an expo-router file to its route.
5
+ *
6
+ * Close to the Next.js app router, and deliberately a SIBLING of pageFileToRoute
7
+ * rather than a parameterisation of it, because the central rule is different:
8
+ * in expo-router **the leaf file IS the route** (`app/login.tsx` -> `/login`),
9
+ * where Next needs a `page.tsx` inside a directory. Sharing one function would
10
+ * mean a `kind` parameter that changes what the last path segment means, which
11
+ * is the sort of coincidence-as-rule NEXT_SCRIPT's own comment warns about.
12
+ */
13
+ export interface ExpoRoute {
14
+ /** URL path with dynamic segments kept in brackets: /items/[id]. */
15
+ urlPath: string;
16
+ /** Dynamic segment names in order: ["id"]. */
17
+ params: string[];
18
+ /** Safe file stem for a spec: items-id. */
19
+ specStem: string;
20
+ /**
21
+ * The `_layout` chain this route mounts inside, outermost first, as paths
22
+ * relative to the router root without extension: ["_layout", "(tabs)/_layout"].
23
+ *
24
+ * renderRouter needs them: a screen under `(tabs)` renders inside that Tabs
25
+ * navigator, and mounting it without its layouts is not the screen the app
26
+ * ships.
27
+ */
28
+ layouts: string[];
29
+ }
30
+
31
+ const ROUTE_EXT = /\.(tsx|jsx|ts|js)$/;
32
+
33
+ /**
34
+ * Files expo-router does not route.
35
+ *
36
+ * `_layout` is a layout. The `+` prefix is RESERVED by expo-router for its own
37
+ * files — `+not-found`, `+html`, `+native-intent`, `+api` — so anything starting
38
+ * with it is excluded by rule rather than by listing the four, which would rot
39
+ * the moment expo-router adds a fifth.
40
+ */
41
+ function isRoutable(base: string): boolean {
42
+ if (base.startsWith("+")) return false;
43
+ return base !== "_layout";
44
+ }
45
+
46
+ export function expoFileToRoute(file: string, routerRoot: string): ExpoRoute | undefined {
47
+ const abs = path.resolve(file);
48
+ const rel = path.relative(path.resolve(routerRoot), abs);
49
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return undefined;
50
+
51
+ const parts = rel.split(path.sep);
52
+ const last = parts.pop();
53
+ if (!last || !ROUTE_EXT.test(last)) return undefined;
54
+ const base = last.replace(ROUTE_EXT, "");
55
+ if (!isRoutable(base)) return undefined;
56
+
57
+ const layouts = layoutChain(parts);
58
+
59
+ // The leaf contributes a segment unless it is `index`, which IS its directory.
60
+ const leafSegments = base === "index" ? [] : [base];
61
+ const segments: string[] = [];
62
+ const params: string[] = [];
63
+
64
+ for (const seg of [...parts, ...leafSegments]) {
65
+ // Route groups vanish from the URL — including as the leaf, so
66
+ // `app/(tabs)/index.tsx` is "/", not "/(tabs)".
67
+ if (/^\(.*\)$/.test(seg)) continue;
68
+ const rest = /^\[\.\.\.(.+)\]$/.exec(seg);
69
+ if (rest?.[1]) {
70
+ params.push(rest[1]);
71
+ segments.push(`[...${rest[1]}]`);
72
+ continue;
73
+ }
74
+ const dynamic = /^\[(.+)\]$/.exec(seg);
75
+ if (dynamic?.[1]) {
76
+ params.push(dynamic[1]);
77
+ segments.push(`[${dynamic[1]}]`);
78
+ continue;
79
+ }
80
+ segments.push(seg);
81
+ }
82
+
83
+ const urlPath = segments.length === 0 ? "/" : `/${segments.join("/")}`;
84
+ return { urlPath, params, specStem: stemFor(segments), layouts };
85
+ }
86
+
87
+ /**
88
+ * The `_layout` chain enclosing a route, outermost first.
89
+ *
90
+ * Derived from the path alone — every ancestor directory MAY have a layout, and
91
+ * whether it does is a filesystem question the caller answers. Returned as
92
+ * extensionless module ids because that is what renderRouter's in-memory context
93
+ * is keyed by.
94
+ */
95
+ function layoutChain(dirs: string[]): string[] {
96
+ const out = ["_layout"];
97
+ const acc: string[] = [];
98
+ for (const d of dirs) {
99
+ acc.push(d);
100
+ out.push(`${acc.join("/")}/_layout`);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * A spec stem. Collisions are REAL here in a way they are not on the Next side:
107
+ * `app/(a)/x.tsx` and `app/(b)/x.tsx` are different routes that both stem to
108
+ * "x", because groups vanish. The caller resolves that with allocateStems below
109
+ * rather than hoping.
110
+ */
111
+ function stemFor(segments: string[]): string {
112
+ if (segments.length === 0) return "index";
113
+ return segments
114
+ .map((s) => s.replace(/\[\.\.\.(.+)\]/, "$1").replace(/\[(.+)\]/, "$1"))
115
+ .join("-")
116
+ .replace(/[^a-zA-Z0-9-]+/g, "-")
117
+ .replace(/-+/g, "-")
118
+ .replace(/^-|-$/g, "");
119
+ }
120
+
121
+ /**
122
+ * Unique stems over the FULL route list.
123
+ *
124
+ * The same invariant allocateSpecNames documents: allocate over everything, then
125
+ * filter. Doing it the other way makes two callers disagree about a name, and
126
+ * here it would silently overwrite one route's spec with another's.
127
+ */
128
+ export function allocateRouteStems(routes: ExpoRoute[]): Map<ExpoRoute, string> {
129
+ const taken = new Set<string>();
130
+ const out = new Map<ExpoRoute, string>();
131
+ for (const r of routes) {
132
+ let stem = r.specStem;
133
+ let i = 2;
134
+ while (taken.has(stem)) stem = `${r.specStem}-${i++}`;
135
+ taken.add(stem);
136
+ out.set(r, stem);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ /** The module id renderRouter's in-memory context keys a file by. */
142
+ export function routeModuleId(file: string, routerRoot: string): string {
143
+ const rel = path.relative(path.resolve(routerRoot), path.resolve(file));
144
+ return rel.split(path.sep).join("/").replace(ROUTE_EXT, "");
145
+ }
package/src/index.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Programmatic API. CLI-equivalent entry point:
3
+ *
4
+ * import { runFlawless } from "flawless-react";
5
+ * const { results, trees, outDir } = await runFlawless({
6
+ * entry: "src/components/Widget.tsx",
7
+ * nextjs: ".",
8
+ * tasks: ["unit-browser", "perf-static"],
9
+ * });
10
+ *
11
+ * Never calls process.exit; throws UsageError for bad input.
12
+ */
13
+ import { type CoreResult, runCore } from "./core.ts";
14
+ import type { PropsShape, RootMode, TaskId } from "./types.ts";
15
+
16
+ export interface RunFlawlessOptions {
17
+ /** Entry .tsx path or glob. Optional in Next.js mode (components glob auto-detected). */
18
+ entry?: string;
19
+ tasks?: TaskId[];
20
+ out?: string;
21
+ /** Next.js project root — enables Next.js mode ("." for cwd). */
22
+ nextjs?: string;
23
+ provider?: string;
24
+ apiSpec?: string;
25
+ swaggerUrl?: string;
26
+ colocate?: boolean;
27
+ /** "exported" emits a tree per exported component in each entry file. */
28
+ roots?: RootMode;
29
+ /** Extra globs to skip, appended to the defaults. */
30
+ exclude?: readonly string[];
31
+ useDefaultExcludes?: boolean;
32
+ variants?: number;
33
+ depth?: number;
34
+ includeChildren?: boolean;
35
+ force?: boolean;
36
+ report?: boolean;
37
+ propsOverride?: PropsShape;
38
+ inlineSummary?: boolean;
39
+ runTests?: boolean;
40
+ /** Progress narration on stdout (default false for the programmatic API). */
41
+ log?: boolean;
42
+ /**
43
+ * Suppress diagnostics on stderr (default false — they always speak, even
44
+ * when `log` is off). An embedder that swallows "could not parse your
45
+ * OpenAPI spec" has the same bug --ci had; opt out only if you route the
46
+ * warnings somewhere yourself.
47
+ */
48
+ quiet?: boolean;
49
+ }
50
+
51
+ export async function runFlawless(options: RunFlawlessOptions): Promise<CoreResult> {
52
+ return runCore({
53
+ pattern: options.entry as string,
54
+ outDir: options.out,
55
+ taskIds: options.tasks,
56
+ depth: options.depth,
57
+ variants: options.variants,
58
+ includeChildren: options.includeChildren,
59
+ force: options.force,
60
+ report: options.report,
61
+ propsOverride: options.propsOverride,
62
+ provider: options.provider,
63
+ apiSpec: options.apiSpec,
64
+ inlineSummary: options.inlineSummary,
65
+ nextjs: options.nextjs,
66
+ swaggerUrl: options.swaggerUrl,
67
+ colocate: options.colocate,
68
+ roots: options.roots,
69
+ exclude: options.exclude,
70
+ useDefaultExcludes: options.useDefaultExcludes,
71
+ runTests: options.runTests,
72
+ log: options.log ?? false,
73
+ quiet: options.quiet,
74
+ });
75
+ }
76
+
77
+ export { analyse, analyseFile, ModuleResolver, parseFile } from "./analyse/index.ts";
78
+ export type { CoreOptions, CoreResult } from "./core.ts";
79
+ export { DEFAULT_EXCLUDES, runCore, UsageError } from "./core.ts";
80
+ export { detectNextjs } from "./nextjs/detect.ts";
81
+ export { scaffold } from "./scaffold/index.ts";
82
+ export { scaffoldUnitBrowser } from "./scaffold-tests/index.ts";
83
+ export { ALL_TASK_IDS, findTask, TASKS } from "./tasks/index.ts";
84
+ export { analyseTree } from "./tasks/staticPerfAnalysis.ts";
85
+ export type {
86
+ ComponentInfo,
87
+ ComponentTree,
88
+ Finding,
89
+ HookDetail,
90
+ PropUsage,
91
+ StateInfo,
92
+ TaskId,
93
+ TaskResult,
94
+ } from "./types.ts";
95
+ export { VERSION } from "./version.ts";