Architecture
How flawless-react splits into an analyse phase and a scaffold phase, what each one does, and the limits the design keeps on purpose
Overview
flawless-react is structured as a task registry. The CLI (src/cli.ts) parses args β or prompts for them β expands the positional glob into entry files, analyses each one into a ComponentTree, then dispatches the selected tasks against a shared RunContext.
Each task under src/tasks/ is self-contained. They share the analyse output and write their own slice of the output directory (profile/, unit/, browser/, e2e/, load/, reports/).
entry.tsx
β
βΌ
βββββββββββββββββββββββββββ
β analyse/ β TypeScript compiler API
β parseFile ββΊ imports β
β β β
β βΌ β
β extractProps β β propsShape + variants + unresolvedProps
β extractHooks β β ["useState", "useEffect", β¦]
β walkChildren β β recursive ComponentTree (cycle-guarded)
ββββββββββββββ¬βββββββββββββ
β
βΌ
ComponentTree
β
βΌ
βββββββββββββββββββββββββββ
β scaffold/ β
β templates ββΊ RenderedFile[]
β index ββΊ Bun.write β
ββββββββββββββ¬βββββββββββββ
β
βΌ
<outDir>/ on disk
Phase 1 β analyse
Implemented under src/analyse/, one file per responsibility. The pieces are kept small enough to swap out individually if you later want to use a different parser (SWC, for instance).
parseFile.ts
Reads the entry file and runs ts.createSourceFile(β¦, ScriptKind.TSX). Two recognisers run in order:
tryReactFCDeclβ matchesconst X: React.FC<Props> = (props) => β¦, including theFunctionComponentalias and the directly-importedFC<Props>form. It capturescomponentName, the body node, and the propsTypeReferenceNode.tryDefaultExportFnβ matchesexport default function X(props: Props) { β¦ }.
If neither matches, the function throws with a descriptive message. The recogniser is deliberately small so the rules stay legible.
parseFile also returns an imports map of local name to module specifier, so child resolution can look up where a JSX tag came from.
extractProps.ts
Walks the resolved InterfaceDeclaration or TypeAliasDeclaration. Same-file types resolve directly; imported ones are chased two hops through resolveModule, with a parse cache so a shadcn-style layer does not re-read the same file dozens of times. For each PropertySignature, the type node is mapped:
| Source type | Default | Variants |
|---|---|---|
string / number / boolean / null | "" / 0 / false / null | β |
T[] | [] | β |
inline { a: string; β¦ } | recurse | β |
"a" / 42 / true (literal) | that literal | β |
"a" | "b" | "c" (union of literals) | first literal | all literals become variant rows |
| anything else | β | added to unresolvedProps |
The variant matrix is pairwise, not cartesian: for each union prop, one row is generated per literal with every other prop left at its default. That keeps --variants honest as a row count rather than a combinatorial estimate.
extractHooks.ts
Visits the component bodyβs descendants once. Any CallExpression whose callee identifier matches /^use[A-Z]/ is collected β both the bare useState() and the namespaced React.useState() forms. The result is deduplicated and sorted.
The list is informational. It appears in the generated specβs output but does not yet drive different rerender strategies. Hook-aware rerender drivers β calling a specific useState setter, say β are a sensible future enhancement.
walkChildren.ts
Walks the JSX in the component body and collects JsxOpeningElement and JsxSelfClosingElement tag names that:
- Start with an uppercase letter, so components rather than DOM elements, and
- Resolve to a locally-imported module β the import specifier starts with
./,../, or/.
Bare specifiers such as react or lodash are ignored. The resolver tries <path>, then <path>.tsx, <path>.ts, <path>/index.tsx, and <path>/index.ts, so ./Foo, ./Foo.tsx, and ./Foo/index.tsx all work.
index.ts β the orchestrator
Recursively analyses each discovered child up to --depth, cycle-guarded by absolute path. If a child file cannot be parsed because it holds no recognisable component, it is skipped silently: composite trees often pull in non-component utilities. Other parse errors propagate.
The output is a ComponentTree:
type ComponentTree = { entry: ComponentInfo; children: ComponentTree[] };
type ComponentInfo = {
componentName: string;
filePath: string;
importSpecifier: string;
propsShape: PropsShape;
variants: PropsShape[];
hooks: string[];
unresolvedProps: string[];
};
Phase 2 β scaffold
Implemented under src/scaffold/.
templates.ts
A pure function: renderSuite({ tree, outDir, variants, report }) β RenderedFile[], where each RenderedFile is { relPath, content, executable? }. There is no file IO here, which keeps the templates trivially testable.
The flat list of components β root plus recursed children, deduped β becomes:
- One import line per component in
host/main.tsx, with the path expressed relative to the host directory. - One entry in the
REGISTRYobject keyed by component name, sowindow.__profile.mount(name, props)can find them. - One spec file per component at
profile/<Name>.profile.spec.ts. Each spec bakes its defaults, variants, and hooks list in as literals β no shared fixture file, no implicit coupling.
The host page initialises react-scan once at module load, buffers any βunnecessary rerenderβ events into scanEvents, and exposes mount, rerender, unmount, getRenderInfo, and getScanEvents on window.__profile. The driver API is deliberately small.
index.ts β the writer
Resolves outDir and writes each RenderedFile with Bun.write β a re-run refreshes derived files rather than refusing (write-once files stay untouched; the suiteβs own playwright.config.ts banner records which tool version wrote it). The executable flag flips a chmod 0o755 on emitted scripts.
Why two phases
The split buys three things:
- Test analysis without producing files. Most unit tests operate on the
ComponentTreedirectly, with nothing written to disk. - Test rendering without touching disk.
renderSuitereturns an array, so the scaffold tests assert on the in-memory result before the integration tests round-trip throughBun.write. - Swap either side independently. A future SWC-based analyser only has to produce a
ComponentTreeand the scaffolder is unchanged. A future scaffolder that emits a Vitest suite instead of a Playwright one only consumes the tree.
What it borrows from crust
Two of the load-bearing pieces here are not original, and it is worth saying where they came from. crust is the sibling project β a pipeline-first test and devops toolkit shipped as one binary β and flawless-react leans on it twice.
The load task emits crust pipelines: load/routes.pipes holds one pipe per route and per
GET API operation the analysed components fetch, and crust runs them. flawless-react does not
implement load generation, percentile maths, or a baseline gate; it writes the pipes and gets
out of the way. See Load pipes with crust.
The docs-lint harness is crustβs, retargeted. crust solved βthe shipped prose drifts from
the code it describesβ with a test that opens its own README, docs and skills and parses every
documented invocation against the live CLI. flawless-react had no such gate, and a single
reading pass found what that costs: a skill documenting a throw the tool never emits, a wrong
exit-code table, a --ci field list two fields stale. The same harness now guards this projectβs
prose β and this websiteβs build runs the same check against the published binary, plus crustβs
own verify-web-links against the deployed site, so a dead link or a stale example fails the
deploy rather than a reader.
What is intentionally absent
These are limits by design, not gaps waiting to be filled.
- No runtime. The CLI never imports React or Playwright. It does not boot a server. It only reads and writes files.
- No type checker. The analyser runs
ts.createSourceFile, neverts.createProgram. There is no type checker anywhere in the tool β no assignability, no inference, no diagnostics. That trades accuracy for speed and for requiring zero project configuration. - No code generation framework. Templates are string concatenations. Easy to read, easy to diff against the generated output, easy to grep.
This page used to say imported types were not resolved across files at all. That stopped being
true. Resolving a Props type from a sibling file, a workspace package, or through a barrel
re-export is now routine, as is reading a z.infer alias back to its schema and a dependencyβs
declared Promise<T> return β all of it through the toolβs own ModuleResolver and
SourceFileCache walking ts.createSourceFile output, not through a program instance. The
distinction that survives is the one above: it resolves declarations, and it never checks
types. Where a declaration cannot be followed to a literal-derivable value, the analyser bails
and plants a marker rather than guessing.