@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.
- 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 +608 -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
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
|
+
}
|
package/src/emitPaths.ts
ADDED
|
@@ -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";
|