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:

  1. tryReactFCDecl β€” matches const X: React.FC<Props> = (props) => …, including the FunctionComponent alias and the directly-imported FC<Props> form. It captures componentName, the body node, and the props TypeReferenceNode.
  2. tryDefaultExportFn β€” matches export 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 typeDefaultVariants
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 literalall 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:

  1. Start with an uppercase letter, so components rather than DOM elements, and
  2. 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 REGISTRY object keyed by component name, so window.__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 ComponentTree directly, with nothing written to disk.
  • Test rendering without touching disk. renderSuite returns an array, so the scaffold tests assert on the in-memory result before the integration tests round-trip through Bun.write.
  • Swap either side independently. A future SWC-based analyser only has to produce a ComponentTree and 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, never ts.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.