diff --git a/.changeset/config.json b/.changeset/config.json
index 44dceab7..00c4aac3 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -16,6 +16,7 @@
"@bippy/e2e-expo",
"@bippy/e2e-next",
"@bippy/e2e-tanstack",
- "@bippy/e2e-vite"
+ "@bippy/e2e-vite",
+ "@bippy/parser"
]
}
diff --git a/package.json b/package.json
index 60998b47..bf271d45 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"fmt": "vp fmt",
"lint": "vp lint",
"check": "vp check",
- "typecheck": "pnpm --filter bippy exec tsc --noEmit && pnpm --filter conformance typecheck",
+ "typecheck": "pnpm --filter bippy exec tsc --noEmit && pnpm --filter conformance typecheck && pnpm --filter @bippy/parser typecheck",
"sherif": "sherif --fix -p './packages/e2e/fixtures/*' -p './packages/expo-playground'",
"changeset": "nr build && changeset",
"version": "changeset version",
diff --git a/packages/parser/.gitignore b/packages/parser/.gitignore
new file mode 100644
index 00000000..eefe9601
--- /dev/null
+++ b/packages/parser/.gitignore
@@ -0,0 +1 @@
+.corpus
diff --git a/packages/parser/README.md b/packages/parser/README.md
new file mode 100644
index 00000000..b2dc48b3
--- /dev/null
+++ b/packages/parser/README.md
@@ -0,0 +1,160 @@
+# @bippy/parser
+
+Builds a React fiber tree from source code without running it. The parser reads a project's modules with `oxc-parser`, links imports with `oxc-resolver`, abstractly interprets each component's render body, and reconciles the resulting elements into the same fiber structure React DOM would commit. Every tree it produces can be checked against a real render captured through Bippy, and the package ships the harness that does so.
+
+This is a private workspace package used for research and tooling; it is not published.
+
+## What it produces
+
+```tsx
+const Toggle = () => {
+ const [isOpen, setOpen] = useState(false);
+ return (
+
+
+ {isOpen && open}
+ {isOpen ?
yes
:
no
}
+
+ );
+};
+```
+
+```sh
+pnpm --filter @bippy/parser inspect tests/fixtures/conditionals.tsx
+```
+
+```
+HostRoot
+└─ Conditionals
+ └─ div
+ ├─ …
+ ├─ Toggle
+ │ └─ div
+ │ ├─ button
+ │ ├─ ? isOpen
+ │ │ ├─ then:
+ │ │ │ └─ section
+ │ │ └─ else: ∅
+ │ └─ ? isOpen
+ │ ├─ then:
+ │ │ └─ p
+ │ └─ else:
+ │ └─ p
+ └─ …
+```
+
+The output is a tree of fibers with three kinds of non-fiber nodes:
+
+- **branch** (`? test`): control flow the analysis could not decide, with one alternative per outcome. The same test string has one outcome within a render, so nested branches on it collapse and values that branched on it are refined on each path.
+- **list** (`* description`): zero or more repetitions of an item shape, produced by `.map()` over data of unknown length.
+- **unknown** (`… description`): a subtree that could be anything, with the reason recorded (`unknown(props.children)`, `state (initially 0)`, `cloneElement of non-element`).
+
+A fiber is **opaque** when its component's implementation is outside the analyzed graph (an external package that is not resolved, or resolved but not parsed). Its children are unknown.
+
+## How it works
+
+```
+source files ─▶ module ─▶ project ─▶ link ─▶ analyze ─▶ fiber ─▶ snapshot
+ parse resolve symbols interpret build compare
+```
+
+| directory | role |
+| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `src/module` | Parses one file with `oxc-parser` into a `ParsedModule`: program, bindings (imports, declarations, exports, re-exports, CommonJS `require`/`exports`), a line index, and AST helpers. |
+| `src/project` | A set of modules under a root directory. Resolves specifiers with `oxc-resolver` honoring `tsconfig.json` paths and extension aliases, tolerates unresolvable `extends`, follows workspace symlinks, and can shadow the file system with in-memory sources for tests. |
+| `src/link` | Resolves a name in one module to the declaration that defines it across imports, exports, namespaces and re-exports (`LinkedSymbol`), identifies references to React's API (`memo`, `forwardRef`, `createContext`, `Fragment`, …) regardless of how they were imported, and records the module-level member assignments that build compound components. |
+| `src/analyze` | The abstract interpreter. Evaluates expressions and statements over `StaticValue`s, models React's API calls, hooks and contexts, classifies function, class, memo, forwardRef, lazy and context components, and evaluates module bindings lazily on demand. |
+| `src/fiber` | Reconciles the element values a render produced into `StaticFiber`s following `ReactChildFiber` and `ReactFiberConfigDOM`: fragments flatten, a lone string child becomes `textContent` rather than a `HostText`, Suspense fallbacks are kept, hoistables and singletons get their tags, and the render/recursion/fiber budgets cut off runaway trees. |
+| `src/snapshot` | A serializable projection (`FiberSnapshot`) shared by the static builder and the runtime capture, a tree printer, and the matcher that decides whether a runtime tree is one of the trees a static snapshot describes. |
+| `src/harness` | Renders a component for real under happy-dom with Bippy observing commits and turns the committed fiber root into a `FiberSnapshot`; `verifySnapshots` compares it with the static one. Exported as `@bippy/parser/harness`. |
+| `src/corpus` | Scans and live-verifies real repositories: checkout, dev server management, a Playwright capture script bundled with Bippy's hook, and report writing. Exported as `@bippy/parser/corpus`. |
+| `scripts` | The `inspect` and `corpus` command-line entry points. |
+
+### Static values
+
+Everything the interpreter computes is a `StaticValue` (`src/analyze/values.ts`): literals, text of unknown content, regular expressions, arrays with optional items, lists, objects with an optional unknown spread, functions with their closure scope, components, elements, module namespaces, external references, standard globals, conditionals and unknowns. The rules that matter most:
+
+- **Conditionals** carry the source text of their test. `conditional("isOpen", a, b)` collapses nested conditionals on `isOpen`, normalizes `!x` to `x` with swapped arms, and folds identical arms. Binary operators, property reads, `cloneElement` and `isValidElement` distribute over the arms.
+- **Narrowing** refines variables along a path: entering `if (user)` drops the nullish arms of `user`, `x?.y === "a"` refines property paths, `switch` cases refine by equality, and every local value that branched on the same test loses its other arm. Writes inside a narrowed path drop the refinement.
+- **Undecided effects**: side effects performed under a branch the analysis could not decide (`items.push(x)` inside `if (flag)`) are recorded as conditional on that branch.
+- **Shapes are trusted only where they are complete.** An object with an unknown spread, an array whose items may be absent, a function whose statics were written by code the analysis did not run: reads of absent keys stay unknown. Otherwise absent keys read `undefined`, as they do at runtime.
+- **State is forgotten.** Hook and class state keeps its shape but not its values, because the runtime tree is observed after effects and updates ran; a fiber under `? isLoading` is expected, not a defect.
+- **Loops** unroll when the trip count is static (including counters assigned in the header, as compiled code emits); otherwise the body runs once under an undecided branch and the result is a list.
+- **Globals** (`Object.assign`, `Array.prototype.slice.call`, `Math`, `Symbol`, `process.env.*`) are first-class values so helpers such as Babel's `_extends` fold.
+
+Diagnostics (`interpreter.diagnostics`) explain what could not be modelled and where.
+
+## API
+
+```ts
+import { createStaticRenderer } from "@bippy/parser";
+
+const renderer = createStaticRenderer({ rootDirectory: "/path/to/app" });
+
+const { root, snapshot, diagnostics } = renderer.renderExport("src/App.tsx", "default");
+const mounts = renderer.findMountPoints("src/main.tsx"); // createRoot().render / hydrateRoot / render
+const value = renderer.getExportValue("src/config.ts", "routes"); // any StaticValue
+```
+
+`StaticRendererOptions` extends `ProjectOptions` (`rootDirectory`, in-memory `files`, `alias`, `moduleDirectories`, `followExternalModules`) with interpreter options (`environment`, `maxCallDepth`), build budgets (`maxRenderDepth`, `maxRecursion`, `maxFiberCount`) and a `timeBudgetMs` after which a render throws `AnalysisTimeoutError`.
+
+To print a tree the way `inspect` does, use `renderSnapshotTree(snapshot)`; `renderOwnerTree(root)` prints the owner tree instead of the parent tree.
+
+## Commands
+
+```sh
+pnpm --filter @bippy/parser test # unit tests and fixture conformance
+pnpm --filter @bippy/parser typecheck
+pnpm --filter @bippy/parser inspect [--export name | --entry] [--owner] [--json] [--ids] [--hooks] [--locations] [--diagnostics]
+pnpm --filter @bippy/parser corpus [options] [name...]
+```
+
+`inspect` renders one export (or, with `--entry`, whatever the file mounts through react-dom) and prints the tree, the fiber count and the unknown count. `--diagnostics` lists what the interpreter could not model.
+
+## Verifying against reality
+
+The static tree is only useful if it agrees with what React commits. Two layers check that.
+
+### Fixture conformance
+
+`tests/fixtures/*.tsx` are small apps covering one feature each (conditionals, lists, context, class components, error boundaries, hooks, HOCs, Suspense, compiled output, path aliases, …). `tests/conformance/fixtures.test.tsx` renders every fixture's default export both ways:
+
+1. statically, with `createStaticRenderer` over the fixtures directory;
+2. for real, with `renderRuntimeSnapshot` from the harness: React DOM under happy-dom, Bippy's hook installed before React loads (`tests/setup.ts`), effects and lazies flushed.
+
+The runtime tree must be one of the trees the static snapshot describes, and unless a fixture exports `minCoverage`, every runtime fiber must be explained by a concrete static fiber. A match cannot be bought with wildcards.
+
+### Matching and coverage
+
+`matchSnapshots` compiles each static child list into a small automaton: fibers are literal states, unknown nodes match any run of siblings, branches are alternations and lists are repetitions. Simulating the automaton over the runtime siblings yields, among all accepting paths, the one that explains the most runtime fibers; wildcards absorb only what no concrete fiber can account for. Names tolerate bundler deconflicting suffixes (`RouterProvider2`), and a Suspense boundary caught suspended at capture time (an Offscreen primary tree beside its fallback fragment) is compared against the fallback the static tree describes.
+
+`verifySnapshots` reports `isMatch`, the mismatches with their runtime paths, fiber and unknown counts, and `coverage = explainedFiberCount / runtimeFiberCount`. A match at 60% coverage is honest but weak; the goal is to raise coverage without ever losing the match.
+
+### Corpus
+
+`src/corpus/repositories.ts` describes 30 open-source React applications (cal.diy, shadcn/ui, excalidraw, tldraw, dub, twenty, formbricks, trigger.dev, novu, chakra-ui, pierre, bulletproof-react, rallly, umami, mantine, react-admin, TanStack Router, React Router, documenso, plane, outline, ai-chatbot, heroui, refine, react-three-fiber, docusaurus, material-ui, payload, supabase, appsmith) with their framework, app directory, entry files and, where the app runs without a backend, how to boot its dev server. `src/corpus/workspace-apps.ts` adds the e2e fixture apps of this monorepo.
+
+```sh
+pnpm --filter @bippy/parser corpus # clone and statically scan every repository
+pnpm --filter @bippy/parser corpus --workspace --live # boot dev servers, capture with Playwright, compare
+pnpm --filter @bippy/parser corpus --offline shadcn-ui/ui # one repository, no network
+```
+
+A **scan** renders every component exported by the app directory and reports how many rendered, crashed or timed out, and how many fibers, unknowns and opaque fibers the trees hold; per-component trees are written under `.corpus/report//`. A **live** run finds the app's mount point, renders that tree statically, starts the dev server, injects a Bippy-based capture script before the page's React loads, snapshots every committed root once the page settles, and verifies the static tree against the largest root. `report.md` summarizes both. Clones and reports live under `.corpus/`, which is ignored by git.
+
+Product apps that need a database or API (cal, dub, supabase, …) are scanned only. Live targets are the workspace apps and the repositories that run without a backend once their dependencies are installed: excalidraw, tldraw, bulletproof-react, react-admin and the TanStack Router example.
+
+## Known limits
+
+- Externals stay opaque unless `followExternalModules` is on or the package is linked through `moduleDirectories`; React itself is always modelled rather than parsed.
+- Module-level statements other than declarations, `X.member = …` and `Object.assign(X, {…})` never run. Bindings such statements mention are marked so their statics read unknown instead of a wrong `undefined`.
+- Values from `Proxy`, `Map`/`Set` contents, `Object.create` prototypes and most host APIs are unknown.
+- Hook and class state, refs read during render, and anything a `useEffect` changes are unknown by design; the tree describes what could render, not one particular commit.
+- One analysis is bounded by `maxCallDepth`, `maxRecursion`, `maxFiberCount` and `timeBudgetMs`; exceeding them yields unknowns or `AnalysisTimeoutError`, not wrong trees.
+
+## Working on it
+
+- Add a fixture under `tests/fixtures/` for any new React behavior, and make it pass conformance at full coverage; set `minCoverage` only for behavior that is unknowable statically.
+- Add interpreter behavior tests to `tests/unit/interpreter.test.ts`; they evaluate a virtual module and compare `describeValue` output.
+- Use `inspect --diagnostics` and the corpus reports' unknown descriptions to find the next thing to model. A wrong known value is a bug; an unknown is a gap.
+- React's own source is the reference: `ReactChildFiber`, `ReactFiberConfigDOM`, `ReactChildren`, `ReactSymbols`, `ReactJSXElement` and `ReactWorkTags` are what `src/fiber` and the element model follow.
diff --git a/packages/parser/package.json b/packages/parser/package.json
new file mode 100644
index 00000000..5fb0b2c4
--- /dev/null
+++ b/packages/parser/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@bippy/parser",
+ "version": "0.0.0",
+ "private": true,
+ "description": "construct react fiber trees from source without running it",
+ "license": "MIT",
+ "author": {
+ "name": "Aiden Bai",
+ "email": "aiden@million.dev"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/aidenybai/bippy.git",
+ "directory": "packages/parser"
+ },
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "exports": {
+ "./package.json": "./package.json",
+ ".": "./src/index.ts",
+ "./harness": "./src/harness/index.ts",
+ "./corpus": "./src/corpus/index.ts"
+ },
+ "scripts": {
+ "test": "vp test --project parser",
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tests/fixtures",
+ "inspect": "tsx scripts/inspect.ts",
+ "corpus": "tsx scripts/corpus.ts",
+ "corpus:live": "tsx scripts/corpus.ts --live"
+ },
+ "dependencies": {
+ "@oxc-project/types": "^0.148.0",
+ "oxc-parser": "^0.148.0",
+ "oxc-resolver": "^11.24.2"
+ },
+ "devDependencies": {
+ "@playwright/test": "latest",
+ "@types/node": "^20",
+ "@types/react": "^19.2.18",
+ "@types/react-dom": "^19.2.4",
+ "bippy": "workspace:*",
+ "happy-dom": "^20.11.6",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "tsx": "^4.21.0",
+ "typescript": "^5.9.3",
+ "vite-plus": "latest"
+ }
+}
diff --git a/packages/parser/scripts/corpus.ts b/packages/parser/scripts/corpus.ts
new file mode 100644
index 00000000..9aa9a18d
--- /dev/null
+++ b/packages/parser/scripts/corpus.ts
@@ -0,0 +1,172 @@
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { parseArgs } from "node:util";
+import {
+ buildCaptureScript,
+ checkoutRepository,
+ CORPUS_REPOSITORIES,
+ type CorpusCheckout,
+ type CorpusEntryResult,
+ type CorpusReport,
+ DEFAULT_SCAN_OPTIONS,
+ formatCorpusReport,
+ scanCheckout,
+ toWorkspaceCheckout,
+ verifyLive,
+ WORKSPACE_APPS,
+ writeCorpusReport,
+} from "../src/corpus/index.js";
+
+const USAGE = `usage: pnpm corpus [options] [name...]
+
+ name repository slug (owner/name) or workspace app name;
+ defaults to every repository
+ --workspace also include the fixture apps of this monorepo
+ --live boot dev servers and compare with what the browser renders
+ --no-scan skip the static scan
+ --externals link into node_modules (default: off for scans, on for live)
+ --max-components stop scanning a repository after n components
+ --cache clone directory (default: packages/parser/.corpus/repos)
+ --links workspace package links (default: packages/parser/.corpus/links)
+ --out report directory (default: packages/parser/.corpus/report)
+ --update fetch the branch tip again for existing clones
+ --offline never clone or fetch`;
+
+const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const monorepoRoot = resolve(packageDirectory, "../..");
+const corpusDirectory = resolve(packageDirectory, ".corpus");
+
+/** A whole app is one tree, so it gets a larger budget than a scanned component. */
+const LIVE_MAX_FIBER_COUNT = 50_000;
+const LIVE_TIME_BUDGET_MS = 120_000;
+
+const { values, positionals } = parseArgs({
+ allowPositionals: true,
+ allowNegative: true,
+ options: {
+ workspace: { type: "boolean", default: false },
+ live: { type: "boolean", default: false },
+ scan: { type: "boolean", default: true },
+ externals: { type: "boolean" },
+ "max-components": { type: "string", default: "0" },
+ cache: { type: "string", default: resolve(corpusDirectory, "repos") },
+ links: { type: "string", default: resolve(corpusDirectory, "links") },
+ out: { type: "string", default: resolve(corpusDirectory, "report") },
+ update: { type: "boolean", default: false },
+ offline: { type: "boolean", default: false },
+ help: { type: "boolean", default: false },
+ },
+});
+
+if (values.help) {
+ console.log(USAGE);
+ process.exit(0);
+}
+
+const log = (name: string, message: string): void => {
+ console.error(`[${new Date().toISOString().slice(11, 19)}] ${name}: ${message}`);
+};
+
+interface Selection {
+ name: string;
+ getCheckout: () => Promise;
+}
+
+const selectEntries = (): Selection[] => {
+ const requested = new Set(positionals);
+ const isRequested = (name: string): boolean => requested.size === 0 || requested.has(name);
+ const repositories: Selection[] = CORPUS_REPOSITORIES.filter((repository) =>
+ isRequested(repository.slug),
+ ).map((repository) => ({
+ name: repository.slug,
+ getCheckout: () =>
+ checkoutRepository(repository, {
+ cacheDirectory: values.cache,
+ linksDirectory: values.links,
+ update: values.update,
+ offline: values.offline,
+ }),
+ }));
+ const apps: Selection[] = WORKSPACE_APPS.filter(
+ (app) => requested.has(app.name) || (values.workspace && requested.size === 0),
+ ).map((app) => ({
+ name: app.name,
+ getCheckout: () => Promise.resolve(toWorkspaceCheckout(app, monorepoRoot)),
+ }));
+ const selected = [...repositories, ...apps];
+ const unknownNames = positionals.filter(
+ (name) => !selected.some((selection) => selection.name === name),
+ );
+ if (unknownNames.length > 0) {
+ console.error(`unknown corpus entries: ${unknownNames.join(", ")}\n\n${USAGE}`);
+ process.exit(1);
+ }
+ return selected;
+};
+
+const runEntry = async (
+ selection: Selection,
+ captureScriptPath: string | null,
+): Promise => {
+ const result: CorpusEntryResult = { name: selection.name, scan: null, live: null, error: null };
+ let checkout: CorpusCheckout;
+ try {
+ log(selection.name, "checking out");
+ checkout = await selection.getCheckout();
+ } catch (error) {
+ result.error = error instanceof Error ? error.message : String(error);
+ log(selection.name, `checkout failed: ${result.error}`);
+ return result;
+ }
+ if (values.scan) {
+ log(selection.name, `scanning ${checkout.appDirectory}`);
+ result.scan = scanCheckout(checkout, {
+ ...DEFAULT_SCAN_OPTIONS,
+ followExternalModules: values.externals ?? false,
+ maxComponents: Number(values["max-components"]),
+ onProgress: (message) => log(selection.name, message),
+ });
+ const { components } = result.scan;
+ log(
+ selection.name,
+ `${components.rendered} components rendered, ${components.crashed} crashed, ${components.fibers} fibers, ${components.unknowns} unknown, ${components.opaque} opaque`,
+ );
+ }
+ if (captureScriptPath && checkout.live) {
+ result.live = await verifyLive(checkout, {
+ captureScriptPath,
+ followExternalModules: values.externals ?? true,
+ maxFiberCount: LIVE_MAX_FIBER_COUNT,
+ timeBudgetMs: LIVE_TIME_BUDGET_MS,
+ onLog: (message) => log(selection.name, message),
+ });
+ const { report, error } = result.live;
+ log(
+ selection.name,
+ error
+ ? `live failed: ${error}`
+ : report
+ ? `live ${report.isMatch ? "match" : "MISMATCH"}, coverage ${(report.coverage * 100).toFixed(1)}%`
+ : "live produced no report",
+ );
+ } else if (values.live) {
+ log(selection.name, "no live target");
+ }
+ return result;
+};
+
+const main = async (): Promise => {
+ const selections = selectEntries();
+ const captureScriptPath = values.live
+ ? await buildCaptureScript(resolve(corpusDirectory, "capture"))
+ : null;
+ const report: CorpusReport = { generatedAt: new Date().toISOString(), entries: [] };
+ for (const selection of selections) {
+ report.entries.push(await runEntry(selection, captureScriptPath));
+ writeCorpusReport(report, values.out);
+ }
+ console.log(formatCorpusReport(report).split("\n## ")[0]);
+ console.error(`\nreport written to ${values.out}`);
+};
+
+await main();
diff --git a/packages/parser/scripts/inspect.ts b/packages/parser/scripts/inspect.ts
new file mode 100644
index 00000000..3a150b15
--- /dev/null
+++ b/packages/parser/scripts/inspect.ts
@@ -0,0 +1,93 @@
+import { existsSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { parseArgs } from "node:util";
+import {
+ createStaticRenderer,
+ findNearestFile,
+ renderOwnerTree,
+ renderSnapshotTree,
+ type StaticRenderResult,
+} from "../src/index.js";
+
+const USAGE = `usage: pnpm inspect [options]
+
+ --export export to render (default: "default")
+ --entry render what the file mounts (createRoot().render / hydrateRoot) instead of an export
+ --root project root (default: nearest package.json directory)
+ --owner print the owner tree instead of the parent tree
+ --json print the snapshot as JSON
+ --ids show fiber ids
+ --hooks show hook calls
+ --locations show source locations
+ --diagnostics print analyzer diagnostics`;
+
+const { values, positionals } = parseArgs({
+ allowPositionals: true,
+ options: {
+ export: { type: "string", default: "default" },
+ entry: { type: "boolean", default: false },
+ root: { type: "string" },
+ owner: { type: "boolean", default: false },
+ json: { type: "boolean", default: false },
+ ids: { type: "boolean", default: false },
+ hooks: { type: "boolean", default: false },
+ locations: { type: "boolean", default: false },
+ diagnostics: { type: "boolean", default: false },
+ },
+});
+
+const [fileArgument] = positionals;
+if (!fileArgument) {
+ console.error(USAGE);
+ process.exit(1);
+}
+const filePath = resolve(fileArgument);
+if (!existsSync(filePath)) {
+ console.error(`no such file: ${filePath}`);
+ process.exit(1);
+}
+const packageJson = findNearestFile(dirname(filePath), ["package.json"], "/");
+const rootDirectory = values.root
+ ? resolve(values.root)
+ : packageJson
+ ? dirname(packageJson)
+ : dirname(filePath);
+
+const renderer = createStaticRenderer({ rootDirectory });
+
+const renderEntry = (): StaticRenderResult => {
+ const mounts = renderer.findMountPoints(filePath);
+ if (mounts.length === 0) {
+ console.error(`${filePath} does not mount anything with react-dom`);
+ process.exit(1);
+ }
+ if (mounts.length > 1) console.error(`${mounts.length} mount points; rendering the first`);
+ return renderer.renderValue(mounts[0].element);
+};
+
+const result = values.entry ? renderEntry() : renderer.renderExport(filePath, values.export);
+const renderOptions = {
+ showIds: values.ids,
+ showHooks: values.hooks,
+ showLocations: values.locations,
+};
+
+if (values.json) {
+ console.log(JSON.stringify(result.snapshot, null, 2));
+} else {
+ console.log(
+ values.owner
+ ? renderOwnerTree(result.snapshot, renderOptions)
+ : renderSnapshotTree(result.snapshot, renderOptions),
+ );
+ console.log(`\n${result.root.fiberCount} fibers, ${result.root.unknownCount} unknown`);
+}
+if (values.diagnostics && result.diagnostics.length > 0) {
+ console.log("\ndiagnostics:");
+ for (const diagnostic of result.diagnostics) {
+ const where = diagnostic.location
+ ? ` (${diagnostic.location.filePath}:${diagnostic.location.line}:${diagnostic.location.column})`
+ : "";
+ console.log(` ${diagnostic.code}: ${diagnostic.message}${where}`);
+ }
+}
diff --git a/packages/parser/src/analyze/access.ts b/packages/parser/src/analyze/access.ts
new file mode 100644
index 00000000..e266cc48
--- /dev/null
+++ b/packages/parser/src/analyze/access.ts
@@ -0,0 +1,305 @@
+import { getReactApiReference } from "../link/react-api.js";
+import { getGlobalMember } from "./globals.js";
+import type { Interpreter } from "./interpreter.js";
+import {
+ type ArrayValue,
+ builtin,
+ type BuiltinComponentName,
+ type ClassComponentDefinition,
+ type ComponentDefinition,
+ type ComponentValue,
+ component,
+ conditional,
+ describeValue,
+ type ExternalValue,
+ getObjectProperty,
+ list,
+ literal,
+ NULL,
+ optional,
+ readItem,
+ selectItem,
+ type StaticValue,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+const BUILTIN_COMPONENT_BY_API: Record = {
+ Fragment: "Fragment",
+ Suspense: "Suspense",
+ SuspenseList: "SuspenseList",
+ unstable_SuspenseList: "SuspenseList",
+ StrictMode: "StrictMode",
+ Profiler: "Profiler",
+ Activity: "Activity",
+ unstable_Activity: "Activity",
+ ViewTransition: "ViewTransition",
+ unstable_ViewTransition: "ViewTransition",
+};
+
+/**
+ * Turns references to React's built-in component types into component
+ * values; every other external reference stays opaque.
+ */
+export const normalizeExternal = (value: ExternalValue): StaticValue => {
+ const reference = getReactApiReference(value);
+ if (!reference || reference.source === "react-dom") return value;
+ const builtinName = BUILTIN_COMPONENT_BY_API[reference.api];
+ return builtinName ? builtin(builtinName) : value;
+};
+
+const accessExternalMember = (value: ExternalValue, member: string): StaticValue =>
+ normalizeExternal({
+ ...value,
+ memberPath: [...value.memberPath, member],
+ name: value.name ? `${value.name}.${member}` : member,
+ });
+
+/** Fields of an element object (`ReactJSXElement.js`), and those development builds add. */
+const ELEMENT_KEYS = new Set(["$$typeof", "type", "key", "ref", "props"]);
+const ELEMENT_DEVELOPMENT_KEYS = new Set([
+ "_owner",
+ "_store",
+ "_debugInfo",
+ "_debugStack",
+ "_debugTask",
+]);
+
+/** Fields of the wrapper objects React's `memo`, `forwardRef`, `lazy` and `createContext` return. */
+const DEFINITION_KEYS: Partial> = {
+ memo: ["$$typeof", "type", "compare"],
+ forwardRef: ["$$typeof", "render"],
+ lazy: ["$$typeof", "_payload", "_init"],
+ context: ["$$typeof", "Provider", "Consumer", "_currentValue", "_currentValue2", "_threadCount"],
+};
+
+const hasStaticMember = (definition: ClassComponentDefinition, key: string): boolean =>
+ definition.members.some((member) => member.isStatic && member.key === key) ||
+ (definition.base !== null && hasStaticMember(definition.base, key));
+
+/** Has `lastIndex`, an own property of every regular expression, along with the prototype. */
+const REGEXP_INSTANCE = /./;
+
+/** The array index a property key denotes, or `null` for any other key. */
+export const getIndex = (key: string): number | null => {
+ const index = Number(key);
+ return Number.isInteger(index) && index >= 0 && String(index) === key ? index : null;
+};
+
+/** `key in value`, when the value's shape decides it; `null` otherwise. */
+export const hasProperty = (value: StaticValue, key: string): boolean | null => {
+ switch (value.kind) {
+ case "object":
+ if (value.properties.has(key) || key in Object.prototype) return true;
+ return value.hasUnknownSpread ? null : false;
+ case "array": {
+ if (value.properties.has(key) || key in Array.prototype) return true;
+ const index = getIndex(key);
+ if (index === null) return false;
+ const firstOptional = value.items.findIndex((item) => item.kind === "optional");
+ if (firstOptional === -1) return index < value.items.length;
+ return index < firstOptional ? true : null;
+ }
+ case "literal":
+ return value.value === null || value.value === undefined ? null : key in Object(value.value);
+ case "element":
+ if (ELEMENT_KEYS.has(key) || key in Object.prototype) return true;
+ return ELEMENT_DEVELOPMENT_KEYS.has(key) ? null : false;
+ case "function":
+ if (value.statics.has(key) || key in Function.prototype) return true;
+ /** Only arrow functions lack one, and the value does not tell them apart. */
+ return value.hasUnknownStatics || key === "prototype" ? null : false;
+ case "component": {
+ if (value.statics.has(key)) return true;
+ if (value.hasUnknownStatics) return null;
+ const definition = value.definition;
+ if (definition.kind === "builtin") return null;
+ if (definition.kind === "class") {
+ return key === "prototype" || key in Function.prototype || hasStaticMember(definition, key);
+ }
+ return key in Object.prototype || (DEFINITION_KEYS[definition.kind]?.includes(key) ?? false);
+ }
+ case "regexp":
+ return key in REGEXP_INSTANCE;
+ case "global":
+ return getGlobalMember(value, key).kind !== "unknown";
+ default:
+ return null;
+ }
+};
+
+/**
+ * Static property read, mirroring what the runtime would observe on each
+ * kind of value. Unknown objects produce unknown properties that remember
+ * the access path so diagnostics stay readable.
+ */
+export const getProperty = (
+ interpreter: Interpreter,
+ target: StaticValue,
+ key: string,
+): StaticValue => {
+ switch (target.kind) {
+ case "object":
+ return getObjectProperty(target, key);
+ case "array": {
+ if (key === "length") {
+ return target.items.some((item) => item.kind === "optional")
+ ? unknown("array.length")
+ : literal(target.items.length);
+ }
+ const index = getIndex(key);
+ if (index !== null) return selectItem(target.items, index);
+ return (
+ target.properties.get(key) ?? (key in Array.prototype ? unknown(`array.${key}`) : UNDEFINED)
+ );
+ }
+ case "list":
+ return key === "length" ? unknown(`${target.description}.length`) : unknown(`list.${key}`);
+ case "element":
+ switch (key) {
+ case "props":
+ return target.props;
+ case "key":
+ return target.key ?? NULL;
+ case "type":
+ return target.type;
+ case "$$typeof":
+ return literal(interpreter.elementType);
+ case "ref":
+ return target.props.properties.get("ref") ?? NULL;
+ default:
+ return ELEMENT_DEVELOPMENT_KEYS.has(key) ? unknown(`element.${key}`) : UNDEFINED;
+ }
+ case "literal": {
+ const { value } = target;
+ if (typeof value === "string") {
+ if (key === "length") return literal(value.length);
+ const index = getIndex(key);
+ if (index !== null) return index < value.length ? literal(value[index]) : UNDEFINED;
+ }
+ return hasProperty(target, key) === false
+ ? UNDEFINED
+ : unknown(`${describeValue(target)}.${key}`);
+ }
+ case "regexp":
+ if (key === "source") return literal(target.pattern);
+ if (key === "flags") return literal(target.flags);
+ return hasProperty(target, key) ? unknown(`/${target.pattern}/.${key}`) : UNDEFINED;
+ case "conditional":
+ return conditional(
+ target.test,
+ getProperty(interpreter, target.whenTrue, key),
+ getProperty(interpreter, target.whenFalse, key),
+ );
+ case "optional":
+ return getProperty(interpreter, readItem(target), key);
+ case "namespace":
+ return interpreter.getModuleExport(target.module, key);
+ case "external":
+ return accessExternalMember(target, key);
+ case "global":
+ return getGlobalMember(target, key);
+ case "component":
+ return target.statics.get(key) ?? getComponentProperty(target, key);
+ case "function": {
+ const assigned = target.statics.get(key);
+ if (assigned) return assigned;
+ if (key === "name") return literal(target.name ?? "");
+ return hasProperty(target, key) === false
+ ? UNDEFINED
+ : unknown(`${target.name ?? "function"}.${key}`);
+ }
+ case "text":
+ return key === "length" ? unknown("text.length") : unknown(`text.${key}`);
+ case "unknown":
+ return unknown(`${target.description}.${key}`);
+ }
+};
+
+/** `$$typeof` of the wrapper objects React creates (`shared/ReactSymbols.js`); classes have none. */
+const TYPEOF_BY_DEFINITION: Partial> = {
+ memo: literal(Symbol.for("react.memo")),
+ forwardRef: literal(Symbol.for("react.forward_ref")),
+ lazy: literal(Symbol.for("react.lazy")),
+ class: UNDEFINED,
+};
+
+/**
+ * Static members of component values. `displayName` reads `undefined`
+ * because an assigned display name is folded into the definition's `name`.
+ */
+const getComponentProperty = (value: ComponentValue, key: string): StaticValue => {
+ const definition = value.definition;
+ if (definition.kind === "context") {
+ if (key === "Provider") return component({ ...definition, role: "provider" }, value.statics);
+ if (key === "Consumer") return component({ ...definition, role: "consumer" }, value.statics);
+ }
+ if (key === "displayName") return UNDEFINED;
+ if (key === "name")
+ return definition.kind === "class" ? literal(definition.name ?? "") : UNDEFINED;
+ if (key === "$$typeof") {
+ return (
+ TYPEOF_BY_DEFINITION[definition.kind] ?? unknown(`${definition.kind} component.$$typeof`)
+ );
+ }
+ if (key === "render" && definition.kind === "forwardRef" && definition.render) {
+ return definition.render;
+ }
+ if (key === "type" && definition.kind === "memo") return definition.inner;
+ if (key === "defaultProps" && definition.kind === "class") {
+ return definition.defaultProps ?? UNDEFINED;
+ }
+ return hasProperty(value, key) === false
+ ? UNDEFINED
+ : unknown(`${definition.kind} component.${key}`);
+};
+
+/** Shape of one element of an iterable value. */
+export const getIterationItem = (value: StaticValue, description: string): StaticValue => {
+ switch (value.kind) {
+ case "list":
+ return value.item;
+ case "array": {
+ const [only] = value.items;
+ if (value.items.length !== 1) return unknown(`item of ${description}`);
+ return only.kind === "optional" ? only.value : only;
+ }
+ default:
+ return unknown(`item of ${description}`);
+ }
+};
+
+/** Appends one level of `value` the way `flat()` does: arrays and lists open up, anything else is kept. */
+export const flattenInto = (items: StaticValue[], value: StaticValue): void => {
+ switch (value.kind) {
+ case "array":
+ items.push(...value.items);
+ return;
+ case "list":
+ items.push({ ...value, isInline: true });
+ return;
+ case "optional": {
+ const inner: StaticValue[] = [];
+ flattenInto(inner, value.value);
+ for (const item of inner) items.push(optional(value.test, item));
+ return;
+ }
+ default:
+ items.push(value);
+ }
+};
+
+/** A mutation that cannot be tracked leaves the array holding any number of unknown items. */
+export const forgetArrayItems = (target: ArrayValue, description: string): StaticValue => {
+ target.items.splice(0, target.items.length, {
+ ...list(unknown(description), description),
+ isInline: true,
+ });
+ return unknown(description);
+};
+
+/** Appends the elements of `...value`; a spread list marks its items as inline siblings. */
+export const spreadInto = (items: StaticValue[], value: StaticValue): void => {
+ if (value.kind === "array" || value.kind === "list") flattenInto(items, value);
+ else items.push(unknown(`spread of ${value.kind}`));
+};
diff --git a/packages/parser/src/analyze/builtins.ts b/packages/parser/src/analyze/builtins.ts
new file mode 100644
index 00000000..6abc7329
--- /dev/null
+++ b/packages/parser/src/analyze/builtins.ts
@@ -0,0 +1,486 @@
+import { flattenInto, forgetArrayItems, getIterationItem } from "./access.js";
+import { type EvaluationContext, isEffectUndecided } from "./interpreter.js";
+import type { CallbackInvoker } from "./react-calls.js";
+import {
+ array,
+ assignStatic,
+ conditional,
+ FALSE,
+ getTruthiness,
+ isNullish,
+ list,
+ literal,
+ mergeObjects,
+ object,
+ optional,
+ SELECTION_LIMIT,
+ selectItem,
+ type StaticValue,
+ text,
+ TRUE,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+const ARRAY_LIKE_METHODS = new Set([
+ "map",
+ "flatMap",
+ "filter",
+ "forEach",
+ "slice",
+ "concat",
+ "reverse",
+ "toReversed",
+ "sort",
+ "toSorted",
+ "flat",
+ "join",
+ "find",
+ "findLast",
+ "at",
+ "some",
+ "every",
+ "includes",
+ "indexOf",
+ "findIndex",
+ "reduce",
+ "reduceRight",
+ "push",
+ "unshift",
+ "pop",
+ "shift",
+ "splice",
+ "entries",
+ "keys",
+ "values",
+]);
+
+const isCallable = (value: StaticValue | undefined): value is StaticValue =>
+ value !== undefined && (value.kind === "function" || value.kind === "global");
+
+/** What `join` writes for a known primitive item or separator: nullish become empty, symbols throw. */
+const asJoinable = (value: StaticValue): string | null => {
+ if (value.kind !== "literal" || typeof value.value === "symbol") return null;
+ return isNullish(value.value) ? "" : String(value.value);
+};
+
+type Verdict = boolean | null;
+
+const presentValue = (item: StaticValue): StaticValue =>
+ item.kind === "optional" ? item.value : item;
+
+interface MatchSearch {
+ items: StaticValue[];
+ verdicts: Verdict[];
+ order: number[];
+ describeTest: (index: number) => string;
+ /** What the search yields for a hit at `index` (`find` the item, `indexOf` its position). */
+ select: (index: number) => StaticValue;
+ /** What it yields when nothing matches. */
+ miss: StaticValue;
+}
+
+/**
+ * The first item, in `order`, whose verdict holds. Undecided verdicts and
+ * items that may be absent each add a branch that falls through to the next
+ * candidate, which is how `find` reads on a filtered array; `null` once the
+ * branching would outgrow its usefulness.
+ */
+const findMatch = ({
+ items,
+ verdicts,
+ order,
+ describeTest,
+ select,
+ miss,
+}: MatchSearch): StaticValue | null => {
+ const candidates: number[] = [];
+ for (const index of order) {
+ if (verdicts[index] === false) continue;
+ candidates.push(index);
+ if (verdicts[index] === true && items[index].kind !== "optional") break;
+ }
+ if (candidates.length > SELECTION_LIMIT) return null;
+ let fallthrough = miss;
+ for (const index of candidates.reverse()) {
+ const item = items[index];
+ const matched: StaticValue =
+ verdicts[index] === true
+ ? select(index)
+ : conditional(describeTest(index), select(index), fallthrough);
+ fallthrough = item.kind === "optional" ? conditional(item.test, matched, fallthrough) : matched;
+ }
+ return fallthrough;
+};
+
+/** Whether `item` is the value `includes`/`indexOf` look for, by SameValueZero on known primitives. */
+const equalsSearched = (item: StaticValue, searched: StaticValue): Verdict => {
+ const candidate = presentValue(item);
+ if (candidate.kind !== "literal" || searched.kind !== "literal") return null;
+ return candidate.value === searched.value || Object.is(candidate.value, searched.value);
+};
+
+/** `some`/`every` decided from per-item verdicts; an absent item cannot decide either. */
+const quantify = (items: StaticValue[], verdicts: Verdict[], isEvery: boolean): StaticValue => {
+ const decides = (verdict: Verdict, index: number): boolean =>
+ verdict === !isEvery && items[index].kind !== "optional";
+ if (verdicts.some(decides)) return literal(!isEvery);
+ if (verdicts.every((verdict) => verdict === isEvery)) return literal(isEvery);
+ return unknown(isEvery ? "every()" : "some()");
+};
+
+/**
+ * Array method semantics on the three iterable shapes: known arrays keep
+ * per-item precision, lists stay lists and unknown receivers become lists
+ * of whatever the callback produces from an unknown item.
+ */
+export const evaluateArrayMethod = (
+ target: StaticValue,
+ method: string,
+ callArguments: StaticValue[],
+ invoke: CallbackInvoker,
+ description: string,
+ context: EvaluationContext,
+): StaticValue | null => {
+ if (!ARRAY_LIKE_METHODS.has(method)) return null;
+ const callDescription = `${description}.${method}()`;
+ const items = target.kind === "array" ? target.items : null;
+ const itemShape = getIterationItem(target, description);
+ const [callback, secondArgument] = callArguments;
+ /** Positions are certain only up to the first item that may be absent. */
+ const hasOptionalBefore = (index: number): boolean =>
+ items !== null && items.slice(0, index).some((item) => item.kind === "optional");
+ const indexAt = (index: number): StaticValue =>
+ hasOptionalBefore(index) ? unknown("index") : literal(index);
+ /** Applies `mapper` to every item; an item that may be absent yields a result that may be absent. */
+ const mapItems = (
+ mapper: (item: StaticValue, index: StaticValue) => StaticValue,
+ isFlat: boolean,
+ ): StaticValue => {
+ if (!items) return list(mapper(itemShape, unknown("index")), description, isFlat);
+ const mapped = items.map((item, index) =>
+ item.kind === "optional"
+ ? optional(item.test, mapper(item.value, indexAt(index)))
+ : mapper(item, indexAt(index)),
+ );
+ if (!isFlat) return array(mapped);
+ const flattened: StaticValue[] = [];
+ for (const mappedItem of mapped) flattenInto(flattened, mappedItem);
+ return array(flattened);
+ };
+ /** Runs a predicate over every present item, in source order. */
+ const testItems = (predicate: StaticValue): Verdict[] =>
+ (items ?? []).map((item, index) =>
+ getTruthiness(invoke(predicate, [presentValue(item), indexAt(index), target])),
+ );
+ const asIndex = (value: StaticValue | undefined, fallback: number): number | null => {
+ if (value === undefined) return fallback;
+ return value.kind === "literal" && typeof value.value === "number" ? value.value : null;
+ };
+ switch (method) {
+ case "map":
+ case "flatMap":
+ if (!isCallable(callback)) return unknown(callDescription);
+ return mapItems(
+ (item, index) => invoke(callback, [item, index, target]),
+ method === "flatMap",
+ );
+ case "filter": {
+ if (!isCallable(callback)) return unknown(callDescription);
+ if (!items) return target.kind === "list" ? target : unknown(callDescription);
+ const verdicts = testItems(callback);
+ const kept: StaticValue[] = [];
+ items.forEach((item, index) => {
+ const verdict = verdicts[index];
+ if (verdict === true) kept.push(item);
+ else if (verdict === null) kept.push(optional(`${callDescription} keeps [${index}]`, item));
+ });
+ return array(kept);
+ }
+ case "find":
+ case "findLast":
+ case "findIndex":
+ case "indexOf": {
+ if (!items || !callback) return unknown(callDescription);
+ const isByValue = method === "indexOf";
+ if (!isByValue && !isCallable(callback)) return unknown(callDescription);
+ const order = items.map((_item, index) => index);
+ if (method === "findLast") order.reverse();
+ const isPosition = method === "findIndex" || isByValue;
+ return (
+ findMatch({
+ items,
+ verdicts: isByValue
+ ? items.map((item) => equalsSearched(item, callback))
+ : testItems(callback),
+ order,
+ describeTest: (index) => `${callDescription} matches [${index}]`,
+ select: isPosition ? indexAt : (index) => presentValue(items[index]),
+ miss: isPosition ? literal(-1) : UNDEFINED,
+ }) ?? unknown(callDescription)
+ );
+ }
+ case "forEach":
+ if (!isCallable(callback)) return UNDEFINED;
+ if (items) mapItems((item, index) => invoke(callback, [item, index, target]), false);
+ else invoke(callback, [itemShape, unknown("index"), target]);
+ return UNDEFINED;
+ case "slice": {
+ if (!items) return target.kind === "list" ? target : unknown(callDescription);
+ const start = asIndex(callback, 0);
+ const end = asIndex(secondArgument, items.length);
+ if (start === null || end === null || hasOptionalBefore(end)) {
+ return list(itemShape, description);
+ }
+ return array(items.slice(start, end));
+ }
+ case "concat": {
+ if (!items) return unknown(callDescription);
+ const combined = [...items];
+ for (const argument of callArguments) flattenInto(combined, argument);
+ return array(combined);
+ }
+ case "reverse":
+ case "toReversed":
+ return items
+ ? array([...items].reverse())
+ : target.kind === "list"
+ ? target
+ : unknown(callDescription);
+ case "sort":
+ case "toSorted":
+ if (items && items.length <= 1) return array(items);
+ return target.kind === "list" ? target : list(itemShape, description);
+ case "flat": {
+ if (!items) {
+ return target.kind === "list"
+ ? list(target.item, description, true)
+ : unknown(callDescription);
+ }
+ const flattened: StaticValue[] = [];
+ for (const item of items) flattenInto(flattened, item);
+ return array(flattened);
+ }
+ case "join": {
+ const separator = callback === undefined ? "," : asJoinable(callback);
+ if (!items || separator === null || hasOptionalBefore(items.length)) {
+ return text(callDescription);
+ }
+ const joinable = items.map(asJoinable);
+ return joinable.every((item) => item !== null)
+ ? literal(joinable.join(separator))
+ : text(callDescription);
+ }
+ case "push":
+ case "unshift": {
+ if (target.kind !== "array") return unknown(callDescription);
+ const undecided = context.undecided;
+ const pushed =
+ undecided && isEffectUndecided(context, target.depth)
+ ? callArguments.map((argument) => ({
+ ...list(argument, `${callDescription} under ${undecided.test}`),
+ isInline: true,
+ }))
+ : callArguments;
+ if (method === "push") target.items.push(...pushed);
+ else target.items.unshift(...pushed);
+ return literal(target.items.length);
+ }
+ case "reduce":
+ case "reduceRight": {
+ if (!items || !isCallable(callback) || hasOptionalBefore(items.length)) {
+ return unknown(callDescription);
+ }
+ const order = items.map((_item, index) => index);
+ if (method === "reduceRight") order.reverse();
+ const hasInitial = callArguments.length > 1;
+ if (!hasInitial && order.length === 0) return unknown(callDescription);
+ let accumulator = hasInitial ? (secondArgument ?? UNDEFINED) : items[order[0]];
+ for (const index of order.slice(hasInitial ? 0 : 1)) {
+ accumulator = invoke(callback, [accumulator, items[index], literal(index), target]);
+ }
+ return accumulator;
+ }
+ case "pop":
+ case "shift": {
+ if (target.kind !== "array") return unknown(callDescription);
+ if (isEffectUndecided(context, target.depth) || hasOptionalBefore(target.items.length)) {
+ return forgetArrayItems(target, description);
+ }
+ return (method === "pop" ? target.items.pop() : target.items.shift()) ?? UNDEFINED;
+ }
+ case "splice": {
+ if (target.kind !== "array") return unknown(callDescription);
+ const [start, deleteCount, ...added] = callArguments;
+ const startIndex = asIndex(start, 0);
+ const deleteCountIndex = asIndex(deleteCount, target.items.length);
+ if (
+ isEffectUndecided(context, target.depth) ||
+ startIndex === null ||
+ deleteCountIndex === null
+ ) {
+ return forgetArrayItems(target, description);
+ }
+ return array(target.items.splice(startIndex, deleteCountIndex, ...added));
+ }
+ case "at": {
+ if (!items || callback?.kind !== "literal" || typeof callback.value !== "number") {
+ return unknown(callDescription);
+ }
+ if (callback.value >= 0) return selectItem(items, callback.value);
+ return hasOptionalBefore(items.length)
+ ? unknown(callDescription)
+ : (items.at(callback.value) ?? UNDEFINED);
+ }
+ case "includes":
+ if (!items || !callback) return unknown(callDescription);
+ return quantify(
+ items,
+ items.map((item) => equalsSearched(item, callback)),
+ false,
+ );
+ case "some":
+ case "every":
+ if (!items || !isCallable(callback)) return unknown(callDescription);
+ return quantify(items, testItems(callback), method === "every");
+ default:
+ return unknown(callDescription);
+ }
+};
+
+const toStringValue = (value: StaticValue, description: string): StaticValue => {
+ if (value.kind === "literal") return literal(String(value.value));
+ return value.kind === "text" ? value : text(description);
+};
+
+/**
+ * Calls on well-known globals (`Object.keys`, `Array.from`, `String(x)`…)
+ * that appear in render code. Returns `null` for anything unmodelled.
+ */
+export const evaluateGlobalCall = (
+ chain: string[],
+ callArguments: StaticValue[],
+ invoke: CallbackInvoker,
+ description: string,
+): StaticValue | null => {
+ const [first, second] = callArguments;
+ switch (chain.join(".")) {
+ case "Object.keys":
+ if (first?.kind === "object" && !first.hasUnknownSpread) {
+ return array([...first.properties.keys()].map((key) => literal(key)));
+ }
+ return unknown(description);
+ case "Object.values":
+ if (first?.kind === "object" && !first.hasUnknownSpread) {
+ return array([...first.properties.values()]);
+ }
+ return unknown(description);
+ case "Object.entries":
+ if (first?.kind === "object" && !first.hasUnknownSpread) {
+ return array(
+ [...first.properties.entries()].map(([key, value]) => array([literal(key), value])),
+ );
+ }
+ return unknown(description);
+ case "Object.assign": {
+ if (!first) return UNDEFINED;
+ for (const source of callArguments.slice(1)) {
+ if (first.kind === "object") {
+ if (source.kind === "object") mergeObjects(first, source);
+ else if (source.kind !== "literal") first.hasUnknownSpread = true;
+ } else if (source.kind === "object") {
+ for (const [key, member] of source.properties) assignStatic(first, key, member);
+ }
+ }
+ return first;
+ }
+ case "Object.freeze":
+ case "Object.seal":
+ case "structuredClone":
+ case "Promise.resolve":
+ return first ?? UNDEFINED;
+ case "Object.defineProperty": {
+ const descriptor = callArguments[2];
+ if (!first || second?.kind !== "literal" || descriptor?.kind !== "object") {
+ return first ?? UNDEFINED;
+ }
+ const key = String(second.value);
+ const value =
+ descriptor.properties.get("value") ??
+ (descriptor.properties.has("get") ? unknown(`accessor ${key}`) : UNDEFINED);
+ if (first.kind === "object") first.properties.set(key, value);
+ else assignStatic(first, key, value);
+ return first;
+ }
+ case "Object.fromEntries": {
+ if (first?.kind !== "array") return unknown(description);
+ const result = object();
+ for (const entry of first.items) {
+ if (entry.kind !== "array" || entry.items[0]?.kind !== "literal") {
+ result.hasUnknownSpread = true;
+ continue;
+ }
+ result.properties.set(String(entry.items[0].value), entry.items[1] ?? UNDEFINED);
+ }
+ return result;
+ }
+ case "Object.create":
+ return object();
+ case "Array.isArray":
+ if (!first || first.kind === "unknown" || first.kind === "conditional")
+ return unknown(description);
+ return first.kind === "array" || first.kind === "list" ? TRUE : FALSE;
+ case "Array.of":
+ return array(callArguments);
+ case "Array.from": {
+ if (!first) return array([]);
+ const mapper = second;
+ if (first.kind === "array") {
+ return isCallable(mapper)
+ ? array(first.items.map((item, index) => invoke(mapper, [item, literal(index)])))
+ : first;
+ }
+ const item = first.kind === "list" ? first.item : unknown(`item of ${description}`);
+ return list(
+ isCallable(mapper) ? invoke(mapper, [item, unknown("index")]) : item,
+ description,
+ );
+ }
+ case "String":
+ return first ? toStringValue(first, description) : literal("");
+ case "String.raw":
+ return text(description);
+ case "Number":
+ case "parseInt":
+ case "parseFloat":
+ if (first?.kind === "literal" && typeof first.value !== "symbol") {
+ return literal(Number(first.value));
+ }
+ return unknown(description);
+ case "Symbol":
+ return literal(Symbol(first?.kind === "literal" ? String(first.value) : description));
+ case "Symbol.for":
+ if (first?.kind === "literal" && typeof first.value === "string") {
+ return literal(Symbol.for(first.value));
+ }
+ return unknown(description);
+ case "Boolean": {
+ if (!first) return FALSE;
+ const truthiness = getTruthiness(first);
+ return truthiness === null ? unknown(description) : literal(truthiness);
+ }
+ case "JSON.stringify":
+ if (first?.kind === "literal") return literal(JSON.stringify(first.value) ?? "undefined");
+ return text(description);
+ case "console.log":
+ case "console.warn":
+ case "console.error":
+ case "console.info":
+ case "console.debug":
+ return UNDEFINED;
+ default:
+ return chain[0] === "Math" || chain[0] === "Date" || chain[0] === "Intl"
+ ? unknown(description)
+ : null;
+ }
+};
diff --git a/packages/parser/src/analyze/calls.ts b/packages/parser/src/analyze/calls.ts
new file mode 100644
index 00000000..a12ad3b8
--- /dev/null
+++ b/packages/parser/src/analyze/calls.ts
@@ -0,0 +1,441 @@
+import type { Argument, CallExpression, Expression } from "@oxc-project/types";
+import { getReactApiReference } from "../link/react-api.js";
+import {
+ collectAssignedNames,
+ getMemberChain,
+ isOptionalSpine,
+ isStringLiteral,
+ unwrapExpression,
+} from "../module/ast.js";
+import { getProperty, spreadInto } from "./access.js";
+import { evaluateArrayMethod, evaluateGlobalCall } from "./builtins.js";
+import { evaluateCompiledClass, getCompiledClass } from "./compiled-classes.js";
+import { readContext } from "./contexts.js";
+import { getPrototypeMethod, isGlobalChain } from "./globals.js";
+import { isBuiltinHookName, modelBuiltinHook } from "./hooks.js";
+import { type EvaluationContext, getReturnValue, type Interpreter } from "./interpreter.js";
+import { isHookCallee, isHookName } from "./naming.js";
+import { bindParameters } from "./patterns.js";
+import { type CallbackInvoker, evaluateReactCall } from "./react-calls.js";
+import {
+ assignVariable,
+ createScope,
+ declareVariable,
+ isEnclosingScope,
+ lookupVariable,
+} from "./scope.js";
+import { evaluateRegExpMethod, evaluateStringMethod } from "./strings.js";
+import {
+ array,
+ conditional,
+ type ExternalValue,
+ FALSE,
+ type FunctionValue,
+ isFullyKnown,
+ isNullishValue,
+ mapConditional,
+ readItem,
+ type StaticValue,
+ text,
+ TRUE,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+const evaluateArguments = (
+ interpreter: Interpreter,
+ callArguments: Argument[],
+ context: EvaluationContext,
+): StaticValue[] => {
+ const values: StaticValue[] = [];
+ for (const argument of callArguments) {
+ if (argument.type === "SpreadElement") {
+ const spread: StaticValue[] = [];
+ spreadInto(spread, interpreter.evaluateExpression(argument.argument, context));
+ values.push(...spread.map(readItem));
+ } else values.push(interpreter.evaluateExpression(argument, context));
+ }
+ return values;
+};
+
+const createInvoker = (interpreter: Interpreter, context: EvaluationContext): CallbackInvoker => {
+ const invoke: CallbackInvoker = (callback, callArguments) => {
+ switch (callback.kind) {
+ case "function":
+ return interpreter.callFunction(callback, callArguments, context);
+ case "global": {
+ const description = `${callback.chain.join(".")}()`;
+ return (
+ evaluateGlobalCall(callback.chain, callArguments, invoke, description) ??
+ unknown(description)
+ );
+ }
+ default:
+ return unknown(`call of ${callback.kind}`);
+ }
+ };
+ return invoke;
+};
+
+const isReactHook = (callee: StaticValue): callee is ExternalValue => {
+ if (callee.kind !== "external") return false;
+ const reference = getReactApiReference(callee);
+ if (reference === null || reference.source !== "react") return false;
+ return isBuiltinHookName(reference.api) || isHookName(reference.api);
+};
+
+/**
+ * Hook calls are recognised the way React Compiler does (by name) and,
+ * for compiled output such as `(0, _react.useState)(…)`, by resolving to
+ * React's API. Built-ins are modelled; project hooks are evaluated.
+ */
+const evaluateHookCall = (
+ interpreter: Interpreter,
+ call: CallExpression,
+ callee: StaticValue,
+ displayName: string,
+ callArguments: StaticValue[],
+ context: EvaluationContext,
+): StaticValue => {
+ const reference = isReactHook(callee) ? getReactApiReference(callee) : null;
+ const isBuiltin = reference !== null && isBuiltinHookName(reference.api);
+ context.hooks?.push({
+ name: displayName,
+ isBuiltin,
+ location: interpreter.getLocation(context.module, call),
+ });
+ if (reference) {
+ return modelBuiltinHook(
+ reference.api,
+ callArguments,
+ createInvoker(interpreter, context),
+ (target) => readContext(context.contexts, target),
+ );
+ }
+ if (callee.kind === "function") return interpreter.callFunction(callee, callArguments, context);
+ return unknown(`${displayName}()`);
+};
+
+/**
+ * A callee that cannot be followed may run the closures it is given before
+ * returning (`reaction.track(() => { result = render(); })`), so whatever
+ * they assign in the scopes enclosing the call is no longer known.
+ */
+const releaseCallbackWrites = (
+ callArguments: StaticValue[],
+ description: string,
+ context: EvaluationContext,
+): void => {
+ for (const argument of callArguments) {
+ if (argument.kind !== "function" || !isEnclosingScope(argument.scope, context.scope)) continue;
+ for (const name of collectAssignedNames(argument.fn)) {
+ if (lookupVariable(argument.scope, name) === undefined) continue;
+ assignVariable(context.scope, name, unknown(`${name} after ${description}`));
+ }
+ }
+};
+
+const invokeValue = (
+ interpreter: Interpreter,
+ callee: StaticValue,
+ callArguments: StaticValue[],
+ call: CallExpression,
+ description: string,
+ context: EvaluationContext,
+): StaticValue => {
+ switch (callee.kind) {
+ case "function":
+ return interpreter.callFunction(callee, callArguments, context);
+ case "global": {
+ const invoke = createInvoker(interpreter, context);
+ const modelled = evaluateGlobalCall(callee.chain, callArguments, invoke, description);
+ if (modelled) return modelled;
+ releaseCallbackWrites(callArguments, description, context);
+ return unknown(description);
+ }
+ case "external": {
+ const modelled = evaluateReactCall(
+ interpreter,
+ callee,
+ callArguments,
+ createInvoker(interpreter, context),
+ call,
+ context,
+ );
+ if (modelled) return modelled;
+ releaseCallbackWrites(callArguments, description, context);
+ return unknown(`${callee.name ?? callee.importedName}()`);
+ }
+ default:
+ releaseCallbackWrites(callArguments, description, context);
+ return unknown(description);
+ }
+};
+
+const evaluateMethodCall = (
+ interpreter: Interpreter,
+ target: StaticValue,
+ method: string,
+ callArguments: StaticValue[],
+ description: string,
+ targetDescription: string,
+ context: EvaluationContext,
+): StaticValue | null => {
+ const invoke = createInvoker(interpreter, context);
+ switch (target.kind) {
+ case "array":
+ case "list":
+ return evaluateArrayMethod(target, method, callArguments, invoke, targetDescription, context);
+ case "unknown":
+ if (method === "then" || method === "catch" || method === "finally")
+ return unknown(description);
+ if (method === "toString" || method === "toLocaleString" || method === "toFixed") {
+ return text(description);
+ }
+ return (
+ evaluateArrayMethod(target, method, callArguments, invoke, targetDescription, context) ??
+ evaluateStringMethod(text(targetDescription), method, callArguments, invoke, description)
+ );
+ case "literal":
+ case "text":
+ return evaluateStringMethod(target, method, callArguments, invoke, description);
+ case "regexp":
+ return evaluateRegExpMethod(target, method, callArguments, invoke, description);
+ case "object": {
+ const member = target.properties.get(method);
+ if (member?.kind === "function") {
+ return interpreter.callFunction(
+ { ...member, thisValue: member.thisValue ?? target },
+ callArguments,
+ context,
+ );
+ }
+ if (member) return null;
+ if (method === "hasOwnProperty" && callArguments[0]?.kind === "literal") {
+ if (target.properties.has(String(callArguments[0].value))) return TRUE;
+ return target.hasUnknownSpread ? unknown(description) : FALSE;
+ }
+ return unknown(description);
+ }
+ case "namespace":
+ if (method === "then") return callArguments[0] ? invoke(callArguments[0], [target]) : target;
+ return null;
+ case "global": {
+ if (method !== "call" && method !== "apply") {
+ return evaluateGlobalCall([...target.chain, method], callArguments, invoke, description);
+ }
+ const [receiver = UNDEFINED, applied] = callArguments;
+ const passed =
+ method === "call"
+ ? callArguments.slice(1)
+ : applied?.kind === "array"
+ ? applied.items
+ : null;
+ if (passed === null) return unknown(description);
+ const prototypeMethod = getPrototypeMethod(target);
+ if (prototypeMethod === null) {
+ return evaluateGlobalCall(target.chain, passed, invoke, description);
+ }
+ /** `Array.prototype.slice.call(list)` is `list.slice()`. */
+ return mapConditional(
+ receiver,
+ (arm) =>
+ evaluateMethodCall(
+ interpreter,
+ arm,
+ prototypeMethod,
+ passed,
+ description,
+ description,
+ context,
+ ) ?? unknown(description),
+ );
+ }
+ case "function":
+ switch (method) {
+ case "bind":
+ return callArguments[0] ? { ...target, thisValue: callArguments[0] } : target;
+ case "call":
+ return interpreter.callFunction(
+ { ...target, thisValue: callArguments[0] ?? target.thisValue },
+ callArguments.slice(1),
+ context,
+ );
+ case "apply": {
+ const applied = callArguments[1];
+ return interpreter.callFunction(
+ { ...target, thisValue: callArguments[0] ?? target.thisValue },
+ applied?.kind === "array" ? applied.items : [],
+ context,
+ );
+ }
+ default:
+ return unknown(description);
+ }
+ default:
+ return null;
+ }
+};
+
+const describeCallee = (
+ interpreter: Interpreter,
+ callee: Expression,
+ context: EvaluationContext,
+): string => getMemberChain(callee)?.join(".") ?? interpreter.getSource(context.module, callee);
+
+export const evaluateCall = (
+ interpreter: Interpreter,
+ call: CallExpression,
+ context: EvaluationContext,
+): StaticValue => {
+ const callee = unwrapExpression(call.callee);
+ const calleeDisplay = describeCallee(interpreter, callee, context);
+ const description = `${calleeDisplay}()`;
+ const callArguments = evaluateArguments(interpreter, call.arguments, context);
+
+ if (callee.type === "Identifier" && callee.name === "require") {
+ const [specifier] = call.arguments;
+ if (specifier && isStringLiteral(specifier)) {
+ const module = interpreter.linker.resolveImportedModule(context.module, specifier.value);
+ if (module) return { kind: "namespace", module };
+ }
+ return unknown(description);
+ }
+
+ if (callee.type === "FunctionExpression") {
+ const compiled = getCompiledClass(call);
+ if (compiled) {
+ return evaluateCompiledClass(interpreter, compiled, callArguments[0] ?? UNDEFINED, context);
+ }
+ }
+
+ if (
+ callee.type === "MemberExpression" &&
+ !callee.computed &&
+ callee.property.type === "Identifier"
+ ) {
+ const method = callee.property.name;
+ const chain = getMemberChain(callee);
+ if (chain && isGlobalChain(chain, context)) {
+ const modelled = evaluateGlobalCall(
+ chain,
+ callArguments,
+ createInvoker(interpreter, context),
+ description,
+ );
+ if (modelled) return modelled;
+ }
+ const target = interpreter.evaluateExpression(callee.object, context);
+ const member = getProperty(interpreter, target, method);
+ if (isHookCallee(callee) || isReactHook(member)) {
+ return evaluateHookCall(interpreter, call, member, calleeDisplay, callArguments, context);
+ }
+ const targetDescription = interpreter.getSource(context.module, callee.object);
+ const isShortCircuiting = isOptionalSpine(call);
+ /** A receiver that depends on a test is called on each arm. */
+ const callOn = (receiver: StaticValue): StaticValue => {
+ if (receiver.kind === "conditional") {
+ return conditional(receiver.test, callOn(receiver.whenTrue), callOn(receiver.whenFalse));
+ }
+ if (isShortCircuiting && isNullishValue(receiver)) return UNDEFINED;
+ const modelled = evaluateMethodCall(
+ interpreter,
+ receiver,
+ method,
+ callArguments,
+ description,
+ targetDescription,
+ context,
+ );
+ if (modelled) return modelled;
+ const receiverMember =
+ receiver === target ? member : getProperty(interpreter, receiver, method);
+ if (isShortCircuiting && isNullishValue(receiverMember)) return UNDEFINED;
+ return invokeValue(interpreter, receiverMember, callArguments, call, description, context);
+ };
+ return callOn(target);
+ }
+
+ if (callee.type === "Identifier" && isGlobalChain([callee.name], context)) {
+ const modelled = evaluateGlobalCall(
+ [callee.name],
+ callArguments,
+ createInvoker(interpreter, context),
+ description,
+ );
+ if (modelled) return modelled;
+ }
+
+ const calleeValue = interpreter.evaluateExpression(callee, context);
+ if (isHookCallee(callee) || isReactHook(calleeValue)) {
+ return evaluateHookCall(interpreter, call, calleeValue, calleeDisplay, callArguments, context);
+ }
+ if (isOptionalSpine(call) && isNullishValue(calleeValue)) return UNDEFINED;
+ return invokeValue(interpreter, calleeValue, callArguments, call, description, context);
+};
+
+/** Nested activations of one function before its recursion evaluates to unknown. */
+const MAX_RECURSION_DEPTH = 8;
+
+/**
+ * Whether a call to a function already on the stack is worth following.
+ * Recursion reaches its base case only through fully known arguments; with
+ * anything unknown in them every level would re-evaluate the same undecided
+ * branches, and each branch that recurses multiplies the work.
+ */
+const isRecursionFollowed = (
+ fn: FunctionValue,
+ callArguments: StaticValue[],
+ context: EvaluationContext,
+): boolean => {
+ const activations = context.activeCalls.get(fn.fn) ?? 0;
+ if (activations === 0) return true;
+ return activations < MAX_RECURSION_DEPTH && callArguments.every(isFullyKnown);
+};
+
+/**
+ * Invokes a closure: parameters bind in a fresh scope under the closure's
+ * defining scope, and `async` results are treated as already awaited since
+ * React awaits server components before reconciling them.
+ */
+export const callFunction = (
+ interpreter: Interpreter,
+ fn: FunctionValue,
+ callArguments: StaticValue[],
+ context: EvaluationContext,
+): StaticValue => {
+ const displayName = fn.name ?? "anonymous function";
+ if (context.callDepth >= interpreter.maxCallDepth) {
+ interpreter.report(
+ "call-depth",
+ `call depth limit reached in ${displayName}`,
+ fn.module,
+ fn.fn,
+ );
+ return unknown(`${displayName}() beyond call depth`);
+ }
+ if (fn.fn.type !== "ArrowFunctionExpression" && fn.fn.generator) {
+ return unknown(`generator ${displayName}()`);
+ }
+ if (!isRecursionFollowed(fn, callArguments, context)) {
+ return unknown(`recursive ${displayName}()`);
+ }
+ const activeCalls = new Map(context.activeCalls);
+ activeCalls.set(fn.fn, (activeCalls.get(fn.fn) ?? 0) + 1);
+ const inner: EvaluationContext = {
+ ...context,
+ module: fn.module,
+ scope: createScope(fn.scope),
+ thisValue: fn.thisValue,
+ callDepth: context.callDepth + 1,
+ activeCalls,
+ };
+ bindParameters(interpreter, fn.fn.params, callArguments, inner);
+ if (fn.fn.type !== "ArrowFunctionExpression") {
+ declareVariable(inner.scope, "arguments", array(callArguments));
+ }
+ if (fn.fn.body === null) return UNDEFINED;
+ if (fn.fn.body.type !== "BlockStatement")
+ return interpreter.evaluateExpression(fn.fn.body, inner);
+ return getReturnValue(interpreter.evaluateStatements(fn.fn.body.body, inner), UNDEFINED);
+};
diff --git a/packages/parser/src/analyze/children.ts b/packages/parser/src/analyze/children.ts
new file mode 100644
index 00000000..e8137e02
--- /dev/null
+++ b/packages/parser/src/analyze/children.ts
@@ -0,0 +1,218 @@
+import type { CallbackInvoker } from "./react-calls.js";
+import {
+ array,
+ type ElementValue,
+ isNullish,
+ list,
+ literal,
+ NULL,
+ type OptionalValue,
+ optional,
+ type StaticValue,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+/**
+ * Mirrors `mapIntoArray` from React's `ReactChildren.js`: leaves are visited
+ * depth-first, the callback sees a running count as its index, and every
+ * mapped element is re-keyed with the `.0` / `.$key` / `prefix/` scheme.
+ * Keys become `null` (statically unknown) as soon as any part depends on a
+ * runtime value.
+ */
+interface ChildTraversal {
+ invoke: CallbackInvoker;
+ results: StaticValue[];
+ count: number;
+ isPrecise: boolean;
+}
+
+const SEPARATOR = ".";
+const SUBSEPARATOR = ":";
+
+const escapeKey = (key: string): string =>
+ `$${key.replace(/[=:]/g, (match) => (match === "=" ? "=0" : "=2"))}`;
+
+const escapeUserProvidedKey = (key: string): string => key.replace(/\/+/g, "$&/");
+
+const joinKeys = (...parts: (string | null)[]): string | null =>
+ parts.every((part) => part !== null) ? parts.join("") : null;
+
+/** A key React would coerce with `"" + key`; `undefined` when the element has no key, `null` when unknowable. */
+const getLiteralKey = (element: ElementValue): string | null | undefined => {
+ if (element.key === null) return undefined;
+ if (element.key.kind !== "literal") return null;
+ return isNullish(element.key.value) ? undefined : String(element.key.value);
+};
+
+const getElementKey = (child: StaticValue, index: number): string | null => {
+ if (child.kind !== "element") return index.toString(36);
+ const key = getLiteralKey(child);
+ if (key === null) return null;
+ return key === undefined ? index.toString(36) : escapeKey(key);
+};
+
+const isNullishChild = (value: StaticValue): boolean =>
+ value.kind === "literal" && (isNullish(value.value) || typeof value.value === "boolean");
+
+const isLeafChild = (value: StaticValue): boolean =>
+ value.kind === "literal" || value.kind === "text" || value.kind === "element";
+
+const toKeyValue = (key: string | null): StaticValue =>
+ key === null ? unknown("Children key") : literal(key);
+
+const withRuntimeKey = (value: StaticValue): StaticValue => {
+ if (value.kind === "element") return { ...value, key: unknown("Children key") };
+ if (value.kind === "array") return array(value.items.map(withRuntimeKey));
+ return value;
+};
+
+const getMappedKey = (
+ child: StaticValue,
+ mapped: ElementValue,
+ childKey: string | null,
+): string | null => {
+ const mappedKey = getLiteralKey(mapped);
+ if (mappedKey === null) return null;
+ if (mappedKey === undefined) return childKey;
+ const originalKey = child.kind === "element" ? getLiteralKey(child) : undefined;
+ if (originalKey === null) return null;
+ const isSameKey = originalKey === mappedKey;
+ return joinKeys(isSameKey ? "" : `${escapeUserProvidedKey(mappedKey)}/`, childKey);
+};
+
+const mapLeaf = (
+ child: StaticValue,
+ traversal: ChildTraversal,
+ callback: StaticValue | null,
+ escapedPrefix: string | null,
+ nameSoFar: string | null,
+): void => {
+ const normalized = isNullishChild(child) ? NULL : child;
+ const index = traversal.isPrecise ? literal(traversal.count) : unknown("index");
+ const mapped = callback ? traversal.invoke(callback, [normalized, index]) : normalized;
+ traversal.count += 1;
+ const childKey = nameSoFar === "" ? joinKeys(SEPARATOR, getElementKey(normalized, 0)) : nameSoFar;
+ if (mapped.kind === "array") {
+ const escapedChildKey = childKey === null ? null : `${escapeUserProvidedKey(childKey)}/`;
+ mapIntoArray(mapped, traversal, null, escapedChildKey, "");
+ return;
+ }
+ if (isNullishChild(mapped)) return;
+ if (mapped.kind === "element") {
+ const key = joinKeys(escapedPrefix, getMappedKey(normalized, mapped, childKey));
+ traversal.results.push({ ...mapped, key: toKeyValue(key) });
+ return;
+ }
+ traversal.results.push(mapped);
+};
+
+/**
+ * A child that may be absent is visited on its own: whatever it maps to is
+ * equally optional, and every later index and key is uncertain.
+ */
+const mapOptionalChild = (
+ child: OptionalValue,
+ traversal: ChildTraversal,
+ callback: StaticValue | null,
+ escapedPrefix: string | null,
+): void => {
+ traversal.isPrecise = false;
+ const inner: ChildTraversal = { ...traversal, results: [] };
+ mapIntoArray(child.value, inner, callback, escapedPrefix, null);
+ for (const result of inner.results) traversal.results.push(optional(child.test, result));
+};
+
+const mapIntoArray = (
+ children: StaticValue,
+ traversal: ChildTraversal,
+ callback: StaticValue | null,
+ escapedPrefix: string | null,
+ nameSoFar: string | null,
+): void => {
+ if (isLeafChild(children)) {
+ mapLeaf(children, traversal, callback, escapedPrefix, nameSoFar);
+ return;
+ }
+ if (children.kind === "array") {
+ const nextNamePrefix = nameSoFar === "" ? SEPARATOR : joinKeys(nameSoFar, SUBSEPARATOR);
+ children.items.forEach((child, index) => {
+ if (child.kind === "optional") {
+ mapOptionalChild(child, traversal, callback, escapedPrefix);
+ return;
+ }
+ mapIntoArray(
+ child,
+ traversal,
+ callback,
+ escapedPrefix,
+ joinKeys(nextNamePrefix, getElementKey(child, index)),
+ );
+ });
+ return;
+ }
+ if (children.kind === "optional") {
+ mapOptionalChild(children, traversal, callback, escapedPrefix);
+ return;
+ }
+ traversal.isPrecise = false;
+ const item = children.kind === "list" ? children.item : unknown("child");
+ const mapped = callback ? traversal.invoke(callback, [item, unknown("index")]) : item;
+ traversal.results.push({ ...list(withRuntimeKey(mapped), "Children.map()"), isInline: true });
+};
+
+const traverseChildren = (
+ children: StaticValue,
+ callback: StaticValue | null,
+ invoke: CallbackInvoker,
+): ChildTraversal => {
+ const traversal: ChildTraversal = { invoke, results: [], count: 0, isPrecise: true };
+ mapIntoArray(children, traversal, callback, "", "");
+ return traversal;
+};
+
+const mapChildren = (
+ children: StaticValue,
+ callback: StaticValue | null,
+ invoke: CallbackInvoker,
+): StaticValue => {
+ if (children.kind === "literal" && isNullish(children.value)) return children;
+ const traversal = traverseChildren(children, callback, invoke);
+ const [only] = traversal.results;
+ if (!traversal.isPrecise && traversal.results.length === 1 && only.kind === "list") {
+ return { ...only, isInline: false };
+ }
+ return array(traversal.results);
+};
+
+/** `React.Children.*`: `map`, `forEach`, `toArray`, `count` and `only`. */
+export const evaluateChildrenApi = (
+ method: string,
+ callArguments: StaticValue[],
+ invoke: CallbackInvoker,
+ description: string,
+): StaticValue => {
+ const [children = UNDEFINED, callback = null] = callArguments;
+ switch (method) {
+ case "map":
+ return mapChildren(children, callback, invoke);
+ case "forEach":
+ mapChildren(children, callback, invoke);
+ return UNDEFINED;
+ case "toArray": {
+ const mapped = mapChildren(children, null, invoke);
+ return mapped.kind === "literal" ? array([]) : mapped;
+ }
+ case "count": {
+ if (children.kind === "literal" && isNullish(children.value)) return literal(0);
+ const traversal = traverseChildren(children, null, invoke);
+ return traversal.isPrecise ? literal(traversal.count) : unknown(description);
+ }
+ case "only":
+ return children.kind === "element" || children.kind === "unknown"
+ ? children
+ : unknown(description);
+ default:
+ return unknown(description);
+ }
+};
diff --git a/packages/parser/src/analyze/compiled-classes.ts b/packages/parser/src/analyze/compiled-classes.ts
new file mode 100644
index 00000000..bc4fdda0
--- /dev/null
+++ b/packages/parser/src/analyze/compiled-classes.ts
@@ -0,0 +1,242 @@
+import type {
+ Argument,
+ CallExpression,
+ Expression,
+ Function as FunctionNode,
+ ObjectExpression,
+ Statement,
+} from "@oxc-project/types";
+import {
+ getFunctionStatements,
+ getMemberChain,
+ isFunctionLike,
+ isStringLiteral,
+ unwrapExpression,
+} from "../module/ast.js";
+import { defineClassComponent } from "./components.js";
+import type { EvaluationContext, Interpreter } from "./interpreter.js";
+import { bindParameters } from "./patterns.js";
+import { createScope, declareVariable } from "./scope.js";
+import type { ClassMember, StaticValue } from "./values.js";
+
+/**
+ * A class as Babel and TypeScript lower it for targets without class
+ * syntax: a wrapper called with the base class that declares the
+ * constructor as a function, attaches members to its prototype and
+ * returns it.
+ */
+export interface CompiledClass {
+ name: string;
+ wrapper: FunctionNode;
+ members: ClassMember[];
+ /** Wrapper statements that are not members: helper calls and variables the members close over. */
+ setup: Statement[];
+}
+
+interface MemberTarget {
+ key: string;
+ isStatic: boolean;
+}
+
+interface MemberCollector {
+ className: string;
+ /** `_proto` in `var _proto = X.prototype`. */
+ prototypeAliases: Set;
+ members: ClassMember[];
+}
+
+/** The `key`, `value` and `get` of a property descriptor written as an object literal. */
+interface Descriptor {
+ key: string | null;
+ value: Expression | null;
+ getter: Expression | null;
+}
+
+const getReturnedName = (statements: Statement[]): string | null => {
+ const last = statements.at(-1);
+ if (last?.type !== "ReturnStatement" || !last.argument) return null;
+ const returned = unwrapExpression(last.argument);
+ return returned.type === "Identifier" ? returned.name : null;
+};
+
+const toMember = (target: MemberTarget, descriptor: Descriptor): ClassMember => {
+ const { key, isStatic } = target;
+ if (isFunctionLike(descriptor.getter))
+ return { key, isStatic, kind: "getter", fn: descriptor.getter };
+ if (isFunctionLike(descriptor.value))
+ return { key, isStatic, kind: "method", fn: descriptor.value };
+ return { key, isStatic, kind: "field", value: descriptor.value };
+};
+
+const readDescriptor = (object: ObjectExpression): Descriptor | null => {
+ const descriptor: Descriptor = { key: null, value: null, getter: null };
+ for (const property of object.properties) {
+ if (property.type !== "Property" || property.computed || property.key.type !== "Identifier") {
+ return null;
+ }
+ switch (property.key.name) {
+ case "key":
+ if (!isStringLiteral(property.value)) return null;
+ descriptor.key = property.value.value;
+ break;
+ case "value":
+ descriptor.value = property.value;
+ break;
+ case "get":
+ descriptor.getter = property.value;
+ break;
+ }
+ }
+ return descriptor;
+};
+
+/** `[{ key: "render", value: function () {} }, …]`, as `_createClass` receives; `[]` when absent. */
+const getDescriptorMembers = (
+ argument: Argument | undefined,
+ isStatic: boolean,
+): ClassMember[] | null => {
+ if (argument === undefined) return [];
+ if (argument.type !== "ArrayExpression") return null;
+ const members: ClassMember[] = [];
+ for (const element of argument.elements) {
+ if (element?.type !== "ObjectExpression") return null;
+ const descriptor = readDescriptor(element);
+ if (descriptor === null || descriptor.key === null) return null;
+ members.push(toMember({ key: descriptor.key, isStatic }, descriptor));
+ }
+ return members;
+};
+
+/** Which member a reference names: `X.prototype.m` and `_proto.m` are instance members, `X.m` a static. */
+const getMemberTarget = (
+ reference: Expression,
+ collector: MemberCollector,
+): MemberTarget | null => {
+ const chain = getMemberChain(reference);
+ if (!chain) return null;
+ const [root, ...path] = chain;
+ if (root === collector.className) {
+ if (path.length === 2 && path[0] === "prototype") return { key: path[1], isStatic: false };
+ if (path.length === 1 && path[0] !== "prototype") return { key: path[0], isStatic: true };
+ return null;
+ }
+ if (collector.prototypeAliases.has(root) && path.length === 1) {
+ return { key: path[0], isStatic: false };
+ }
+ return null;
+};
+
+const isPrototypeAliasDeclaration = (statement: Statement, collector: MemberCollector): boolean => {
+ if (statement.type !== "VariableDeclaration" || statement.declarations.length !== 1) return false;
+ const [declarator] = statement.declarations;
+ if (declarator.id.type !== "Identifier" || !declarator.init) return false;
+ const chain = getMemberChain(declarator.init);
+ if (chain?.length !== 2 || chain[0] !== collector.className || chain[1] !== "prototype") {
+ return false;
+ }
+ collector.prototypeAliases.add(declarator.id.name);
+ return true;
+};
+
+/**
+ * Records the member a wrapper statement attaches, if any: a prototype or
+ * static assignment, `Object.defineProperty` on the class or its prototype,
+ * or a `_createClass(X, protoProps, staticProps)` descriptor list.
+ */
+const collectMemberStatement = (statement: Statement, collector: MemberCollector): boolean => {
+ if (isPrototypeAliasDeclaration(statement, collector)) return true;
+ if (statement.type !== "ExpressionStatement") return false;
+ const expression = unwrapExpression(statement.expression);
+ if (expression.type === "AssignmentExpression") {
+ if (expression.operator !== "=" || expression.left.type !== "MemberExpression") return false;
+ const target = getMemberTarget(expression.left, collector);
+ if (!target) return false;
+ collector.members.push(toMember(target, { key: null, value: expression.right, getter: null }));
+ return true;
+ }
+ if (expression.type !== "CallExpression") return false;
+ const [receiver, second, third] = expression.arguments;
+ if (getMemberChain(expression.callee)?.join(".") === "Object.defineProperty") {
+ if (!receiver || receiver.type === "SpreadElement" || !isStringLiteral(second)) return false;
+ const descriptor = third?.type === "ObjectExpression" ? readDescriptor(third) : null;
+ const chain = getMemberChain(receiver);
+ if (!descriptor || !chain) return false;
+ const isClass = chain.length === 1 && chain[0] === collector.className;
+ if (!isClass && !isPrototypeChain(chain, collector)) return false;
+ collector.members.push(toMember({ key: second.value, isStatic: isClass }, descriptor));
+ return true;
+ }
+ if (receiver?.type !== "Identifier" || receiver.name !== collector.className) return false;
+ if (second === undefined) return false;
+ const instanceMembers = getDescriptorMembers(second, false);
+ const staticMembers = getDescriptorMembers(third, true);
+ if (!instanceMembers || !staticMembers) return false;
+ collector.members.push(...instanceMembers, ...staticMembers);
+ return true;
+};
+
+/** `X.prototype` or an alias of it. */
+const isPrototypeChain = (chain: string[], collector: MemberCollector): boolean =>
+ (chain.length === 2 && chain[0] === collector.className && chain[1] === "prototype") ||
+ (chain.length === 1 && collector.prototypeAliases.has(chain[0]));
+
+/**
+ * Recognises `(function (_Base) { …; function X() {} …; return X; })(Base)`
+ * with at least one member attached to `X` besides its constructor.
+ */
+export const getCompiledClass = (call: CallExpression): CompiledClass | null => {
+ const wrapper = unwrapExpression(call.callee);
+ if (wrapper.type !== "FunctionExpression") return null;
+ if (wrapper.params.length !== 1 || call.arguments.length !== 1) return null;
+ const statements = getFunctionStatements(wrapper);
+ if (!statements) return null;
+ const name = getReturnedName(statements);
+ if (name === null) return null;
+ const constructorFn = statements.find(
+ (statement) => statement.type === "FunctionDeclaration" && statement.id?.name === name,
+ );
+ if (!constructorFn || constructorFn.type !== "FunctionDeclaration") return null;
+ const collector: MemberCollector = {
+ className: name,
+ prototypeAliases: new Set(),
+ members: [{ key: "constructor", isStatic: false, kind: "constructor", fn: constructorFn }],
+ };
+ const setup: Statement[] = [];
+ for (const statement of statements.slice(0, -1)) {
+ if (statement === constructorFn || collectMemberStatement(statement, collector)) continue;
+ setup.push(statement);
+ }
+ if (collector.members.length === 1) return null;
+ return { name, wrapper, members: collector.members, setup };
+};
+
+/**
+ * The wrapper runs once: its parameter is the base class, the constructor is
+ * hoisted as the class itself so helpers such as `_createSuper(X)` see it,
+ * and the remaining setup runs for whatever the members close over.
+ */
+export const evaluateCompiledClass = (
+ interpreter: Interpreter,
+ compiled: CompiledClass,
+ superValue: StaticValue,
+ context: EvaluationContext,
+): StaticValue => {
+ const scope = createScope(context.scope);
+ const wrapperContext: EvaluationContext = { ...context, scope };
+ bindParameters(interpreter, compiled.wrapper.params, [superValue], wrapperContext);
+ const value = defineClassComponent(
+ interpreter,
+ {
+ name: compiled.name,
+ module: context.module,
+ scope,
+ members: compiled.members,
+ superValue,
+ span: compiled.wrapper,
+ },
+ wrapperContext,
+ );
+ declareVariable(scope, compiled.name, value);
+ interpreter.evaluateStatements(compiled.setup, wrapperContext);
+ return value;
+};
diff --git a/packages/parser/src/analyze/components.ts b/packages/parser/src/analyze/components.ts
new file mode 100644
index 00000000..efd49e07
--- /dev/null
+++ b/packages/parser/src/analyze/components.ts
@@ -0,0 +1,264 @@
+import type { Class, Span } from "@oxc-project/types";
+import { getReactApiReference, REACT_BASE_CLASSES } from "../link/react-api.js";
+import { type FunctionLike, isFunctionLike } from "../module/ast.js";
+import type { ParsedModule } from "../module/types.js";
+import { readContext } from "./contexts.js";
+import type { EvaluationContext, Interpreter } from "./interpreter.js";
+import { getPropertyKeyName } from "./patterns.js";
+import { createScope, type Scope } from "./scope.js";
+import {
+ type ClassComponentDefinition,
+ type ClassMember,
+ component,
+ type FunctionValue,
+ NULL,
+ object,
+ type ObjectValue,
+ type StaticValue,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+/** A class before it is known to be a component: its members and what it extends. */
+export interface ClassSource {
+ name: string | null;
+ module: ParsedModule;
+ scope: Scope;
+ members: ClassMember[];
+ superValue: StaticValue;
+ span: Span;
+}
+
+/** The members of class syntax, with computed keys resolved in the class's scope. */
+const collectClassMembers = (
+ interpreter: Interpreter,
+ classNode: Class,
+ context: EvaluationContext,
+): ClassMember[] => {
+ const members: ClassMember[] = [];
+ for (const element of classNode.body.body) {
+ if (element.type !== "MethodDefinition" && element.type !== "PropertyDefinition") continue;
+ const key = getPropertyKeyName(interpreter, element.key, element.computed, context);
+ if (key === null) continue;
+ if (!("kind" in element)) {
+ members.push({ key, isStatic: element.static, kind: "field", value: element.value });
+ } else if (element.kind !== "set") {
+ const kind = element.kind === "get" ? "getter" : element.kind;
+ members.push({ key, isStatic: element.static, kind, fn: element.value });
+ }
+ }
+ return members;
+};
+
+const hasErrorBoundaryMembers = (members: ClassMember[]): boolean =>
+ members.some(
+ (member) =>
+ (member.key === "componentDidCatch" && !member.isStatic) ||
+ (member.key === "getDerivedStateFromError" && member.isStatic),
+ );
+
+const getStaticProperty = (
+ interpreter: Interpreter,
+ members: ClassMember[],
+ name: string,
+ context: EvaluationContext,
+): StaticValue | null => {
+ for (const member of members) {
+ if (!member.isStatic || member.key !== name || member.kind !== "field" || !member.value)
+ continue;
+ return interpreter.evaluateExpression(member.value, context);
+ }
+ return null;
+};
+
+/**
+ * Defines a class as a React class component when it extends
+ * `React.Component`/`PureComponent`, another class component, or at least
+ * defines `render()` under an unresolved base class.
+ */
+export const defineClassComponent = (
+ interpreter: Interpreter,
+ source: ClassSource,
+ context: EvaluationContext,
+): StaticValue => {
+ const { superValue, members } = source;
+ const reactApi = superValue.kind === "external" ? getReactApiReference(superValue) : null;
+ const extendsReactComponent = reactApi !== null && REACT_BASE_CLASSES.has(reactApi.api);
+ const base =
+ superValue.kind === "component" && superValue.definition.kind === "class"
+ ? superValue.definition
+ : null;
+ const hasRender = members.some((member) => member.key === "render" && !member.isStatic);
+ if (!extendsReactComponent && !base && !hasRender) {
+ return unknown(`class ${source.name ?? "anonymous"}`);
+ }
+ const defaultProps = getStaticProperty(interpreter, members, "defaultProps", context);
+ const displayName = getStaticProperty(interpreter, members, "displayName", context);
+ return component({
+ kind: "class",
+ name:
+ displayName?.kind === "literal" && typeof displayName.value === "string"
+ ? displayName.value
+ : source.name,
+ module: source.module,
+ members,
+ scope: source.scope,
+ base,
+ defaultProps: defaultProps?.kind === "object" ? defaultProps : (base?.defaultProps ?? null),
+ contextType:
+ getStaticProperty(interpreter, members, "contextType", context) ?? base?.contextType ?? null,
+ isErrorBoundary: hasErrorBoundaryMembers(members) || (base?.isErrorBoundary ?? false),
+ span: source.span,
+ });
+};
+
+export const classifyClass = (
+ interpreter: Interpreter,
+ classNode: Class,
+ module: ParsedModule,
+ scope: Scope,
+ nameHint: string | null,
+ context: EvaluationContext,
+): StaticValue => {
+ const name = classNode.id?.name ?? nameHint;
+ if (!classNode.superClass) return unknown(`class ${name ?? "anonymous"}`);
+ return defineClassComponent(
+ interpreter,
+ {
+ name,
+ module,
+ scope,
+ members: collectClassMembers(interpreter, classNode, context),
+ superValue: interpreter.evaluateExpression(classNode.superClass, context),
+ span: { start: classNode.start, end: classNode.end },
+ },
+ context,
+ );
+};
+
+/** `resolveClassComponentProps`: `defaultProps` fill in props that are `undefined`. */
+export const resolveClassProps = (
+ definition: ClassComponentDefinition,
+ props: ObjectValue,
+): ObjectValue => {
+ if (!definition.defaultProps) return props;
+ const resolved = object(props.properties, props.hasUnknownSpread, props.depth);
+ for (const [key, defaultValue] of definition.defaultProps.properties) {
+ const given = resolved.properties.get(key);
+ if (given === undefined || (given.kind === "literal" && given.value === undefined)) {
+ resolved.properties.set(key, defaultValue);
+ }
+ }
+ return resolved;
+};
+
+/** State is observed after updates, so its keys are kept and its values forgotten. */
+const forgetStateValues = (state: StaticValue): StaticValue => {
+ if (state.kind !== "object") return state.kind === "literal" ? state : unknown("this.state");
+ return object(
+ [...state.properties.keys()].map((key) => [key, unknown(`this.state.${key}`)]),
+ true,
+ );
+};
+
+/** The class chain from the outermost base to the class itself. */
+const getClassChain = (definition: ClassComponentDefinition): ClassComponentDefinition[] =>
+ definition.base ? [...getClassChain(definition.base), definition] : [definition];
+
+interface InstanceMembers {
+ constructorMethod: FunctionValue | null;
+}
+
+const installMembers = (
+ interpreter: Interpreter,
+ definition: ClassComponentDefinition,
+ instance: ObjectValue,
+ members: InstanceMembers,
+ context: EvaluationContext,
+): void => {
+ const instanceContext: EvaluationContext = {
+ ...context,
+ module: definition.module,
+ scope: createScope(definition.scope),
+ thisValue: instance,
+ hooks: null,
+ };
+ const asMethod = (fn: FunctionLike, name: string): FunctionValue => ({
+ kind: "function",
+ fn,
+ module: definition.module,
+ scope: definition.scope,
+ thisValue: instance,
+ name,
+ statics: new Map(),
+ hasUnknownStatics: false,
+ });
+ for (const member of definition.members) {
+ if (member.isStatic) continue;
+ switch (member.kind) {
+ case "constructor":
+ members.constructorMethod = asMethod(member.fn, "constructor");
+ break;
+ case "getter":
+ instance.properties.set(
+ member.key,
+ interpreter.callFunction(asMethod(member.fn, member.key), [], instanceContext),
+ );
+ break;
+ case "method":
+ instance.properties.set(member.key, asMethod(member.fn, member.key));
+ break;
+ case "field":
+ instance.properties.set(
+ member.key,
+ member.value === null
+ ? UNDEFINED
+ : isFunctionLike(member.value)
+ ? asMethod(member.value, member.key)
+ : interpreter.evaluateExpression(member.value, instanceContext),
+ );
+ break;
+ }
+ }
+};
+
+/**
+ * Builds the `this` an instance of a class component observes during
+ * `render()`: props, fields and methods from the base classes down, then
+ * whatever the most derived constructor assigned.
+ */
+export const instantiateClassComponent = (
+ interpreter: Interpreter,
+ definition: ClassComponentDefinition,
+ props: ObjectValue,
+ context: EvaluationContext,
+): { instance: ObjectValue; render: FunctionValue | null } => {
+ const instance = object([
+ ["props", props],
+ ["state", NULL],
+ [
+ "context",
+ definition.contextType ? readContext(context.contexts, definition.contextType) : UNDEFINED,
+ ],
+ ["setState", unknown("this.setState")],
+ ["forceUpdate", unknown("this.forceUpdate")],
+ ["refs", unknown("this.refs")],
+ ]);
+ const members: InstanceMembers = { constructorMethod: null };
+ for (const ancestor of getClassChain(definition))
+ installMembers(interpreter, ancestor, instance, members, context);
+ if (members.constructorMethod) {
+ const constructorContext: EvaluationContext = {
+ ...context,
+ module: members.constructorMethod.module,
+ scope: createScope(members.constructorMethod.scope),
+ thisValue: instance,
+ hooks: null,
+ };
+ interpreter.callFunction(members.constructorMethod, [props], constructorContext);
+ }
+ const state = instance.properties.get("state");
+ if (state) instance.properties.set("state", forgetStateValues(state));
+ const render = instance.properties.get("render");
+ return { instance, render: render?.kind === "function" ? render : null };
+};
diff --git a/packages/parser/src/analyze/contexts.ts b/packages/parser/src/analyze/contexts.ts
new file mode 100644
index 00000000..9fcca71a
--- /dev/null
+++ b/packages/parser/src/analyze/contexts.ts
@@ -0,0 +1,28 @@
+import type { ContextDefinition, StaticValue } from "./values.js";
+import { unknown } from "./values.js";
+
+/** Values provided by enclosing `` fibers, keyed by context identity. */
+export type ProvidedContexts = ReadonlyMap;
+
+export const EMPTY_CONTEXTS: ProvidedContexts = new Map();
+
+export const getContextKey = (definition: ContextDefinition): string =>
+ `${definition.module.filePath}@${definition.span.start}`;
+
+export const provideContext = (
+ contexts: ProvidedContexts,
+ definition: ContextDefinition,
+ value: StaticValue,
+): ProvidedContexts => new Map(contexts).set(getContextKey(definition), value);
+
+/**
+ * What `useContext(Context)` / `use(Context)` / `` observe:
+ * the nearest provided value, else the default. Anything that is not a
+ * context object stays unknown.
+ */
+export const readContext = (contexts: ProvidedContexts, target: StaticValue): StaticValue => {
+ if (target.kind !== "component" || target.definition.kind !== "context") {
+ return unknown("context value");
+ }
+ return contexts.get(getContextKey(target.definition)) ?? target.definition.defaultValue;
+};
diff --git a/packages/parser/src/analyze/enums.ts b/packages/parser/src/analyze/enums.ts
new file mode 100644
index 00000000..ba513940
--- /dev/null
+++ b/packages/parser/src/analyze/enums.ts
@@ -0,0 +1,51 @@
+import type { TSEnumDeclaration, TSEnumMemberName } from "@oxc-project/types";
+import type { EvaluationContext, Interpreter } from "./interpreter.js";
+import { createScope, declareVariable } from "./scope.js";
+import { literal, object, type ObjectValue, type StaticValue, unknown } from "./values.js";
+
+const getMemberName = (name: TSEnumMemberName): string | null => {
+ switch (name.type) {
+ case "Identifier":
+ return name.name;
+ case "Literal":
+ return name.value;
+ case "TemplateLiteral":
+ return name.expressions.length === 0 ? (name.quasis[0]?.value.cooked ?? null) : null;
+ }
+};
+
+/**
+ * A TypeScript enum compiles to an object. Members without an initializer
+ * count up from the previous numeric member, numeric members also get a
+ * reverse mapping from value to name, and initializers may refer to earlier
+ * members by their bare name.
+ */
+export const evaluateEnum = (
+ interpreter: Interpreter,
+ declaration: TSEnumDeclaration,
+ context: EvaluationContext,
+): ObjectValue => {
+ const members = object();
+ const memberScope = createScope(context.scope);
+ const memberContext: EvaluationContext = { ...context, scope: memberScope };
+ let nextNumber: number | null = 0;
+ for (const member of declaration.body.members) {
+ const name = getMemberName(member.id);
+ if (name === null) continue;
+ let value: StaticValue;
+ if (member.initializer) {
+ value = interpreter.evaluateExpression(member.initializer, memberContext);
+ } else if (nextNumber !== null) {
+ value = literal(nextNumber);
+ } else {
+ value = unknown(`enum member ${declaration.id.name}.${name} follows a computed member`);
+ }
+ const numericValue =
+ value.kind === "literal" && typeof value.value === "number" ? value.value : null;
+ nextNumber = numericValue === null ? null : numericValue + 1;
+ members.properties.set(name, value);
+ if (numericValue !== null) members.properties.set(String(numericValue), literal(name));
+ declareVariable(memberScope, name, value);
+ }
+ return members;
+};
diff --git a/packages/parser/src/analyze/expressions.ts b/packages/parser/src/analyze/expressions.ts
new file mode 100644
index 00000000..08abb6b3
--- /dev/null
+++ b/packages/parser/src/analyze/expressions.ts
@@ -0,0 +1,499 @@
+import type {
+ AssignmentExpression,
+ AssignmentOperator,
+ Expression,
+ LogicalExpression,
+ LogicalOperator,
+ MemberExpression,
+ ObjectExpression,
+ Span,
+ TaggedTemplateExpression,
+ TemplateLiteral,
+} from "@oxc-project/types";
+import {
+ getMemberLinks,
+ isAnonymousFunctionDefinition,
+ isOptionalSpine,
+ isStringLiteral,
+ type MemberLink,
+} from "../module/ast.js";
+import { getProperty, normalizeExternal, spreadInto } from "./access.js";
+import {
+ getStandardGlobal,
+ isGlobalChain,
+ isKnownGlobal,
+ readEnvironmentVariable,
+} from "./globals.js";
+import { evaluateCall } from "./calls.js";
+import { classifyClass } from "./components.js";
+import {
+ enterUndecided,
+ type EvaluationContext,
+ getUndecidedDepth,
+ type Interpreter,
+} from "./interpreter.js";
+import { evaluateJsxElement, evaluateJsxFragment } from "./jsx.js";
+import {
+ collectNarrowings,
+ keepArms,
+ keepFalsy,
+ keepNonNullish,
+ keepNullish,
+ keepTruthy,
+ narrowScope,
+} from "./narrowing.js";
+import { applyBinaryOperator, applyUnaryOperator, getBinaryOperator } from "./operators.js";
+import { assignToTarget, getPropertyKeyName } from "./patterns.js";
+import { lookupVariable } from "./scope.js";
+import {
+ array,
+ conditional,
+ type ExternalValue,
+ FALSE,
+ type FunctionValue,
+ getTruthiness,
+ isNullishValue,
+ literal,
+ mapConditional,
+ mergeObjects,
+ nameValue,
+ object,
+ readItem,
+ regexp,
+ type StaticValue,
+ text,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+const GLOBAL_LITERALS: Record = {
+ undefined: UNDEFINED,
+ NaN: literal(Number.NaN),
+ Infinity: literal(Number.POSITIVE_INFINITY),
+};
+
+/** `React` used without an import: UMD globals and the classic JSX pragma. */
+const REACT_GLOBAL: ExternalValue = {
+ kind: "external",
+ specifier: "react",
+ packageName: "react",
+ importedName: "*",
+ memberPath: [],
+ name: "React",
+};
+
+const resolveIdentifier = (
+ interpreter: Interpreter,
+ name: string,
+ span: Span,
+ context: EvaluationContext,
+): StaticValue => {
+ const local = lookupVariable(context.scope, name);
+ if (local !== undefined) return local;
+ const moduleValue = interpreter.resolveModuleBinding(context.module, name);
+ if (moduleValue) return moduleValue;
+ const globalLiteral = GLOBAL_LITERALS[name];
+ if (globalLiteral) return globalLiteral;
+ if (name === "React") return REACT_GLOBAL;
+ const standard = getStandardGlobal(name);
+ if (standard) return standard;
+ if (!isKnownGlobal(name)) {
+ interpreter.report("unresolved-reference", `no binding for "${name}"`, context.module, span);
+ }
+ return unknown(`global ${name}`);
+};
+
+/**
+ * Reads `key` off each possible target. Inside an optional chain a nullish
+ * target short-circuits to `undefined` instead of failing the read.
+ */
+const accessMember = (
+ interpreter: Interpreter,
+ target: StaticValue,
+ key: string,
+ isShortCircuiting: boolean,
+): StaticValue =>
+ mapConditional(target, (arm) =>
+ isShortCircuiting && isNullishValue(arm) ? UNDEFINED : getProperty(interpreter, arm, key),
+ );
+
+const accessLinks = (
+ interpreter: Interpreter,
+ links: MemberLink[],
+ span: Span,
+ context: EvaluationContext,
+): StaticValue => {
+ const chain = links.map((link) => link.name);
+ const firstOptional = links.findIndex((link) => link.isOptional);
+ const isEnvironmentRead =
+ chain[0] === "process" &&
+ chain[1] === "env" &&
+ chain.length > 2 &&
+ isGlobalChain(chain, context);
+ let value = isEnvironmentRead
+ ? readEnvironmentVariable(interpreter, chain[2])
+ : resolveIdentifier(interpreter, chain[0], span, context);
+ for (let index = isEnvironmentRead ? 3 : 1; index < chain.length; index++) {
+ const isShortCircuiting = firstOptional !== -1 && index >= firstOptional;
+ value = accessMember(interpreter, value, chain[index], isShortCircuiting);
+ }
+ return value;
+};
+
+export const evaluateChain = (
+ interpreter: Interpreter,
+ chain: string[],
+ span: Span,
+ context: EvaluationContext,
+): StaticValue =>
+ accessLinks(
+ interpreter,
+ chain.map((name) => ({ name, isOptional: false })),
+ span,
+ context,
+ );
+
+const evaluateMember = (
+ interpreter: Interpreter,
+ expression: MemberExpression,
+ context: EvaluationContext,
+): StaticValue => {
+ const links = getMemberLinks(expression);
+ if (links && links[0].name !== "this")
+ return accessLinks(interpreter, links, expression, context);
+ const target = interpreter.evaluateExpression(expression.object, context);
+ const key = getPropertyKeyName(interpreter, expression.property, expression.computed, context);
+ if (key !== null) return accessMember(interpreter, target, key, isOptionalSpine(expression));
+ if (target.kind === "list") return target.item;
+ if (target.kind === "array" && target.items.length === 1) return readItem(target.items[0]);
+ return unknown(interpreter.getSource(context.module, expression));
+};
+
+/**
+ * `a && b` with an undecidable `a` yields `b` or nothing: a falsy left
+ * operand is assumed to be one React skips (`false`, `null`, `undefined`,
+ * `""`) rather than a `0` that would render as text.
+ */
+/** The context for code that only runs once `test` had `outcome`. */
+const enterOutcome = (
+ interpreter: Interpreter,
+ context: EvaluationContext,
+ test: Expression,
+ testSource: string,
+ outcome: boolean,
+): EvaluationContext => {
+ const narrowings = collectNarrowings(test, outcome, (expression) =>
+ interpreter.getSource(context.module, expression),
+ );
+ return enterUndecided(context, testSource, narrowScope(context.scope, narrowings));
+};
+
+const evaluateLogical = (
+ interpreter: Interpreter,
+ expression: LogicalExpression,
+ context: EvaluationContext,
+): StaticValue => {
+ const left = interpreter.evaluateExpression(expression.left, context);
+ const test = interpreter.getSource(context.module, expression.left);
+ return applyLogicalOperator(expression.operator, left, test, () =>
+ interpreter.evaluateExpression(
+ expression.right,
+ expression.operator === "??"
+ ? enterUndecided(context, test, context.scope)
+ : enterOutcome(interpreter, context, expression.left, test, expression.operator === "&&"),
+ ),
+ );
+};
+
+/**
+ * Short-circuits when the left side decides; otherwise both sides remain
+ * possible, and the arm that keeps the left side only keeps what the
+ * operator lets through (`a || b` yields `a` only where `a` is truthy).
+ */
+const applyLogicalOperator = (
+ operator: LogicalOperator,
+ left: StaticValue,
+ test: string,
+ right: () => StaticValue,
+): StaticValue => {
+ const truthiness = getTruthiness(left);
+ switch (operator) {
+ case "&&": {
+ if (truthiness === false) return left;
+ if (truthiness === true) return right();
+ /** An unknown that turned out falsy is modelled as `false`, the boolean case. */
+ const falsyLeft = mapConditional(keepArms(left, keepFalsy) ?? FALSE, (arm) =>
+ arm.kind === "literal" ? arm : FALSE,
+ );
+ return conditional(test, right(), falsyLeft);
+ }
+ case "||":
+ if (truthiness === true) return left;
+ if (truthiness === false) return right();
+ return conditional(test, keepArms(left, keepTruthy) ?? left, right());
+ case "??": {
+ const nonNullish = keepArms(left, keepNonNullish);
+ if (nonNullish === null) return right();
+ if (keepArms(left, keepNullish) === null) return left;
+ return conditional(`${test} != null`, nonNullish, right());
+ }
+ }
+};
+
+const LOGICAL_ASSIGNMENT_OPERATORS: Partial> = {
+ "&&=": "&&",
+ "||=": "||",
+ "??=": "??",
+};
+
+/** `x += y`, `x ||= y`: the current value combined with the right side. */
+const evaluateCompoundAssignment = (
+ interpreter: Interpreter,
+ expression: AssignmentExpression,
+ right: StaticValue,
+ context: EvaluationContext,
+): StaticValue => {
+ const target = expression.left;
+ const source = interpreter.getSource(context.module, expression);
+ if (target.type === "ArrayPattern" || target.type === "ObjectPattern") return unknown(source);
+ const current = interpreter.evaluateExpression(target, context);
+ const logicalOperator = LOGICAL_ASSIGNMENT_OPERATORS[expression.operator];
+ if (logicalOperator) {
+ return applyLogicalOperator(
+ logicalOperator,
+ current,
+ interpreter.getSource(context.module, target),
+ () => right,
+ );
+ }
+ const binaryOperator = getBinaryOperator(expression.operator);
+ return binaryOperator
+ ? applyBinaryOperator(binaryOperator, current, right, source)
+ : unknown(source);
+};
+
+const evaluateTemplate = (
+ interpreter: Interpreter,
+ expression: TemplateLiteral,
+ context: EvaluationContext,
+): StaticValue => {
+ let result = "";
+ for (const [index, quasi] of expression.quasis.entries()) {
+ result += quasi.value.cooked ?? quasi.value.raw;
+ const inner = expression.expressions[index];
+ if (!inner) continue;
+ const value = interpreter.evaluateExpression(inner, context);
+ if (value.kind !== "literal") return text(interpreter.getSource(context.module, expression));
+ result += String(value.value);
+ }
+ return literal(result);
+};
+
+const evaluateTaggedTemplate = (
+ interpreter: Interpreter,
+ expression: TaggedTemplateExpression,
+ context: EvaluationContext,
+): StaticValue => {
+ const tag = interpreter.evaluateExpression(expression.tag, context);
+ const source = interpreter.getSource(context.module, expression);
+ if (tag.kind !== "function") return unknown(source);
+ const strings = array(
+ expression.quasi.quasis.map((quasi) => literal(quasi.value.cooked ?? quasi.value.raw)),
+ );
+ const values = expression.quasi.expressions.map((inner) =>
+ interpreter.evaluateExpression(inner, context),
+ );
+ return interpreter.callFunction(tag, [strings, ...values], context);
+};
+
+const evaluateObject = (
+ interpreter: Interpreter,
+ expression: ObjectExpression,
+ context: EvaluationContext,
+): StaticValue => {
+ const result = object([], false, getUndecidedDepth(context));
+ for (const property of expression.properties) {
+ if (property.type === "SpreadElement") {
+ const spread = interpreter.evaluateExpression(property.argument, context);
+ if (spread.kind === "object") mergeObjects(result, spread);
+ else if (spread.kind !== "literal") result.hasUnknownSpread = true;
+ continue;
+ }
+ const key = getPropertyKeyName(interpreter, property.key, property.computed, context);
+ if (key === null) {
+ result.hasUnknownSpread = true;
+ continue;
+ }
+ if (property.kind !== "init") {
+ result.properties.set(key, unknown(`accessor ${key}`));
+ continue;
+ }
+ let value = interpreter.evaluateExpression(property.value, context);
+ if (value.kind === "function" && property.method) value = { ...value, thisValue: result };
+ result.properties.set(
+ key,
+ nameValue(value, key, isAnonymousFunctionDefinition(property.value)),
+ );
+ }
+ return result;
+};
+
+const createFunctionValue = (
+ fn: FunctionValue["fn"],
+ context: EvaluationContext,
+): FunctionValue => ({
+ kind: "function",
+ fn,
+ module: context.module,
+ scope: context.scope,
+ thisValue: fn.type === "ArrowFunctionExpression" ? context.thisValue : null,
+ name: fn.type === "ArrowFunctionExpression" ? null : (fn.id?.name ?? null),
+ statics: new Map(),
+ hasUnknownStatics: false,
+});
+
+export const evaluateExpression = (
+ interpreter: Interpreter,
+ expression: Expression,
+ context: EvaluationContext,
+): StaticValue => {
+ const source = (): string => interpreter.getSource(context.module, expression);
+ switch (expression.type) {
+ case "Literal":
+ return "regex" in expression
+ ? regexp(expression.regex.pattern, expression.regex.flags)
+ : literal(expression.value);
+ case "TemplateLiteral":
+ return evaluateTemplate(interpreter, expression, context);
+ case "Identifier":
+ return resolveIdentifier(interpreter, expression.name, expression, context);
+ case "ThisExpression":
+ return context.thisValue ?? unknown("this");
+ case "ArrayExpression": {
+ const items: StaticValue[] = [];
+ for (const element of expression.elements) {
+ if (element === null) items.push(UNDEFINED);
+ else if (element.type === "SpreadElement") {
+ spreadInto(items, interpreter.evaluateExpression(element.argument, context));
+ } else items.push(interpreter.evaluateExpression(element, context));
+ }
+ return array(items, getUndecidedDepth(context));
+ }
+ case "ObjectExpression":
+ return evaluateObject(interpreter, expression, context);
+ case "ArrowFunctionExpression":
+ case "FunctionDeclaration":
+ case "FunctionExpression":
+ return createFunctionValue(expression, context);
+ case "TSDeclareFunction":
+ case "TSEmptyBodyFunctionExpression":
+ return unknown(source());
+ case "ClassDeclaration":
+ case "ClassExpression":
+ return classifyClass(interpreter, expression, context.module, context.scope, null, context);
+ case "ConditionalExpression": {
+ const test = interpreter.evaluateExpression(expression.test, context);
+ const truthiness = getTruthiness(test);
+ if (truthiness === true)
+ return interpreter.evaluateExpression(expression.consequent, context);
+ if (truthiness === false)
+ return interpreter.evaluateExpression(expression.alternate, context);
+ const testSource = interpreter.getSource(context.module, expression.test);
+ return conditional(
+ testSource,
+ interpreter.evaluateExpression(
+ expression.consequent,
+ enterOutcome(interpreter, context, expression.test, testSource, true),
+ ),
+ interpreter.evaluateExpression(
+ expression.alternate,
+ enterOutcome(interpreter, context, expression.test, testSource, false),
+ ),
+ );
+ }
+ case "LogicalExpression":
+ return evaluateLogical(interpreter, expression, context);
+ case "MemberExpression":
+ return evaluateMember(interpreter, expression, context);
+ case "ChainExpression":
+ return interpreter.evaluateExpression(expression.expression, context);
+ case "CallExpression":
+ return evaluateCall(interpreter, expression, context);
+ case "NewExpression":
+ return unknown(source());
+ case "SequenceExpression": {
+ let last: StaticValue = UNDEFINED;
+ for (const inner of expression.expressions)
+ last = interpreter.evaluateExpression(inner, context);
+ return last;
+ }
+ case "AssignmentExpression": {
+ const right = interpreter.evaluateExpression(expression.right, context);
+ const value =
+ expression.operator === "="
+ ? right
+ : evaluateCompoundAssignment(interpreter, expression, right, context);
+ assignToTarget(interpreter, expression.left, value, context);
+ return value;
+ }
+ case "UpdateExpression": {
+ const current = interpreter.evaluateExpression(expression.argument, context);
+ const updated =
+ current.kind === "literal" && typeof current.value === "number"
+ ? literal(current.value + (expression.operator === "++" ? 1 : -1))
+ : unknown(source());
+ assignToTarget(interpreter, expression.argument, updated, context);
+ return expression.prefix ? updated : current;
+ }
+ case "UnaryExpression":
+ return applyUnaryOperator(
+ expression.operator,
+ interpreter.evaluateExpression(expression.argument, context),
+ source(),
+ );
+ case "BinaryExpression":
+ if (expression.left.type === "PrivateIdentifier") return unknown(source());
+ return applyBinaryOperator(
+ expression.operator,
+ interpreter.evaluateExpression(expression.left, context),
+ interpreter.evaluateExpression(expression.right, context),
+ source(),
+ );
+ case "AwaitExpression":
+ return interpreter.evaluateExpression(expression.argument, context);
+ case "TaggedTemplateExpression":
+ return evaluateTaggedTemplate(interpreter, expression, context);
+ case "ParenthesizedExpression":
+ case "TSAsExpression":
+ case "TSSatisfiesExpression":
+ case "TSNonNullExpression":
+ case "TSTypeAssertion":
+ case "TSInstantiationExpression":
+ return interpreter.evaluateExpression(expression.expression, context);
+ case "ImportExpression": {
+ if (!isStringLiteral(expression.source)) return unknown(source());
+ const module = interpreter.linker.resolveImportedModule(
+ context.module,
+ expression.source.value,
+ );
+ if (module) return { kind: "namespace", module };
+ return normalizeExternal({
+ kind: "external",
+ specifier: expression.source.value,
+ packageName: null,
+ importedName: "*",
+ memberPath: [],
+ name: null,
+ });
+ }
+ case "JSXElement":
+ return evaluateJsxElement(interpreter, expression, context);
+ case "JSXFragment":
+ return evaluateJsxFragment(interpreter, expression, context);
+ case "YieldExpression":
+ case "MetaProperty":
+ case "Super":
+ case "V8IntrinsicExpression":
+ return unknown(source());
+ }
+};
diff --git a/packages/parser/src/analyze/globals.ts b/packages/parser/src/analyze/globals.ts
new file mode 100644
index 00000000..7ed419b8
--- /dev/null
+++ b/packages/parser/src/analyze/globals.ts
@@ -0,0 +1,165 @@
+import type { EvaluationContext, Interpreter } from "./interpreter.js";
+import { hasLocalBinding } from "./scope.js";
+import { type GlobalValue, literal, type Primitive, type StaticValue, unknown } from "./values.js";
+
+/** Globals the ECMAScript standard defines, so the analysis host has the same members. */
+const STANDARD_NAMESPACES = new Set([
+ "Object",
+ "Array",
+ "JSON",
+ "Math",
+ "String",
+ "Number",
+ "Boolean",
+ "Date",
+ "Promise",
+ "Symbol",
+ "Reflect",
+ "Intl",
+]);
+
+const GLOBAL_NAMESPACES = new Set([
+ ...STANDARD_NAMESPACES,
+ "console",
+ "window",
+ "document",
+ "globalThis",
+ "navigator",
+ "process",
+]);
+
+/** Browser globals the analysis host (Node) does not define. */
+const DOM_GLOBALS = new Set([
+ "location",
+ "history",
+ "screen",
+ "self",
+ "parent",
+ "top",
+ "frames",
+ "localStorage",
+ "sessionStorage",
+ "indexedDB",
+ "caches",
+ "requestAnimationFrame",
+ "cancelAnimationFrame",
+ "requestIdleCallback",
+ "cancelIdleCallback",
+ "matchMedia",
+ "getComputedStyle",
+ "getSelection",
+ "scrollTo",
+ "scrollBy",
+ "alert",
+ "confirm",
+ "prompt",
+ "open",
+ "print",
+ "innerWidth",
+ "innerHeight",
+ "devicePixelRatio",
+ "Image",
+ "Audio",
+ "Option",
+ "FileReader",
+ "XMLHttpRequest",
+ "Worker",
+ "DOMParser",
+ "XMLSerializer",
+ "Notification",
+ "MutationObserver",
+ "IntersectionObserver",
+ "ResizeObserver",
+ "CSS",
+ "Node",
+ "Text",
+ "Element",
+ "Range",
+ "Selection",
+ "NodeList",
+ "DocumentFragment",
+ "ShadowRoot",
+ "DataTransfer",
+ "MediaQueryList",
+ "ImageData",
+ "Path2D",
+ "OffscreenCanvas",
+ "AudioContext",
+ "MediaRecorder",
+ "MediaStream",
+ "IDBKeyRange",
+]);
+
+const DOM_GLOBAL_PATTERN = /^(?:HTML|SVG|CSS|Webkit|WebKit)[A-Z]|Event$|Element$/;
+
+/**
+ * Whether a free identifier names a value the runtime provides. Anything
+ * ECMAScript or the web platform defines in Node is checked against the host,
+ * so the list only has to cover what browsers add on top.
+ */
+export const isKnownGlobal = (name: string): boolean =>
+ GLOBAL_NAMESPACES.has(name) ||
+ DOM_GLOBALS.has(name) ||
+ DOM_GLOBAL_PATTERN.test(name) ||
+ name in globalThis;
+
+/** Whether an access chain starts at a global namespace rather than a binding that shadows it. */
+export const isGlobalChain = (chain: string[], context: EvaluationContext): boolean =>
+ GLOBAL_NAMESPACES.has(chain[0]) &&
+ !hasLocalBinding(context.scope, chain[0]) &&
+ !context.module.bindings.has(chain[0]);
+
+/**
+ * `process.env.` as a bundler substitutes it: the configured value, or
+ * unknown for a variable the analysis was not told about.
+ */
+export const readEnvironmentVariable = (interpreter: Interpreter, name: string): StaticValue => {
+ const value = interpreter.environment[name];
+ return value === undefined ? unknown(`process.env.${name}`) : literal(value);
+};
+
+const isHostObject = (value: unknown): value is object =>
+ typeof value === "function" || (typeof value === "object" && value !== null);
+
+const isHostPrimitive = (value: unknown): value is Primitive => !isHostObject(value);
+
+/** The host's own value at `chain`, or `undefined` when the path is not defined or cannot be read. */
+const getHostValue = (chain: string[]): unknown => {
+ let current: unknown = globalThis;
+ for (const key of chain) {
+ if (!isHostObject(current) || !(key in current)) return undefined;
+ try {
+ current = Reflect.get(current, key);
+ } catch {
+ return undefined;
+ }
+ }
+ return current;
+};
+
+const toGlobal = (chain: string[], host: object): GlobalValue => ({
+ kind: "global",
+ chain,
+ typeName: typeof host === "function" ? "function" : "object",
+});
+
+/** A free identifier naming a standard global, as a value. */
+export const getStandardGlobal = (name: string): GlobalValue | null => {
+ const host = getHostValue([name]);
+ return STANDARD_NAMESPACES.has(name) && isHostObject(host) ? toGlobal([name], host) : null;
+};
+
+/** Whether the standard method at `chain` is one `Namespace.prototype.method` that a `.call` hands a receiver. */
+export const getPrototypeMethod = (value: GlobalValue): string | null =>
+ value.chain.length === 3 && value.chain[1] === "prototype" ? value.chain[2] : null;
+
+/**
+ * A member of a standard global: functions and namespaces stay globals,
+ * constants (`Math.PI`, `Symbol.iterator`) fold to what the host holds.
+ */
+export const getGlobalMember = (target: GlobalValue, key: string): StaticValue => {
+ const chain = [...target.chain, key];
+ const host = getHostValue(chain);
+ if (isHostObject(host)) return toGlobal(chain, host);
+ return host !== undefined && isHostPrimitive(host) ? literal(host) : unknown(chain.join("."));
+};
diff --git a/packages/parser/src/analyze/hooks.ts b/packages/parser/src/analyze/hooks.ts
new file mode 100644
index 00000000..419cabbf
--- /dev/null
+++ b/packages/parser/src/analyze/hooks.ts
@@ -0,0 +1,105 @@
+import type { SourceLocation } from "../module/location.js";
+import {
+ array,
+ describeValue,
+ object,
+ type StaticValue,
+ text,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+export interface HookCall {
+ /** Callee as written, e.g. `useState` or `React.useEffect`. */
+ name: string;
+ isBuiltin: boolean;
+ location: SourceLocation | null;
+}
+
+const BUILTIN_HOOK_NAMES = new Set([
+ "use",
+ "useActionState",
+ "useCallback",
+ "useContext",
+ "useDebugValue",
+ "useDeferredValue",
+ "useEffect",
+ "useEffectEvent",
+ "useFormStatus",
+ "useId",
+ "useImperativeHandle",
+ "useInsertionEffect",
+ "useLayoutEffect",
+ "useMemo",
+ "useOptimistic",
+ "useReducer",
+ "useRef",
+ "useState",
+ "useSyncExternalStore",
+ "useTransition",
+]);
+
+export const isBuiltinHookName = (name: string): boolean => BUILTIN_HOOK_NAMES.has(name);
+
+/**
+ * Models the return value of a React hook for the first render. State is
+ * treated as unknown rather than its initial value: the runtime tree is
+ * observed after effects ran, so branching on state must stay a branch.
+ * `useMemo` callbacks are evaluated because they must be pure.
+ */
+export const modelBuiltinHook = (
+ name: string,
+ args: StaticValue[],
+ callFunction: (fn: StaticValue, callArguments: StaticValue[]) => StaticValue,
+ readContext: (target: StaticValue) => StaticValue,
+): StaticValue => {
+ const [first, second] = args;
+ switch (name) {
+ case "useState":
+ return array([
+ unknown(`state${first ? ` (initially ${describeValue(first)})` : ""}`),
+ unknown("setState"),
+ ]);
+ case "useReducer":
+ return array([unknown("reducer state"), unknown("dispatch")]);
+ case "useRef":
+ return object([["current", first ?? UNDEFINED]]);
+ case "useMemo":
+ return first ? callFunction(first, []) : unknown("useMemo()");
+ case "useCallback":
+ case "useEffectEvent":
+ return first ?? unknown(`${name}()`);
+ case "useDeferredValue":
+ return first ?? UNDEFINED;
+ case "useOptimistic":
+ return array([first ?? UNDEFINED, unknown("setOptimistic")]);
+ case "useActionState":
+ return array([second ?? UNDEFINED, unknown("formAction"), unknown("isPending")]);
+ case "useTransition":
+ return array([unknown("isPending"), unknown("startTransition")]);
+ case "useFormStatus":
+ return object([
+ ["pending", unknown("pending")],
+ ["data", unknown("data")],
+ ["method", unknown("method")],
+ ["action", unknown("action")],
+ ]);
+ case "useId":
+ return text("useId()");
+ case "useEffect":
+ case "useLayoutEffect":
+ case "useInsertionEffect":
+ case "useImperativeHandle":
+ case "useDebugValue":
+ return UNDEFINED;
+ case "useContext":
+ return first ? readContext(first) : unknown("useContext()");
+ case "useSyncExternalStore":
+ return unknown("external store snapshot");
+ case "use":
+ if (first?.kind === "component") return readContext(first);
+ return unknown(`use(${first ? describeValue(first) : ""})`);
+ default:
+ return unknown(`${name}()`);
+ }
+};
diff --git a/packages/parser/src/analyze/index.ts b/packages/parser/src/analyze/index.ts
new file mode 100644
index 00000000..3861ba16
--- /dev/null
+++ b/packages/parser/src/analyze/index.ts
@@ -0,0 +1,115 @@
+import type { Span } from "@oxc-project/types";
+import type { Linker } from "../link/linker.js";
+import { getReactElementType } from "../link/react-api.js";
+import type { SourceLocation } from "../module/location.js";
+import type { ParsedModule } from "../module/types.js";
+import type { Project } from "../project/project.js";
+import { callFunction } from "./calls.js";
+import { EMPTY_CONTEXTS } from "./contexts.js";
+import { evaluateChain, evaluateExpression } from "./expressions.js";
+import {
+ AnalysisTimeoutError,
+ type Diagnostic,
+ type EvaluationContext,
+ getSourcePreview,
+ type Interpreter,
+ type InterpreterOptions,
+} from "./interpreter.js";
+import { evaluateStatements } from "./statements.js";
+import {
+ getModuleExport,
+ getModuleScope,
+ resolveModuleBinding,
+ valueFromSymbol,
+} from "./symbols.js";
+
+export const DEFAULT_MAX_CALL_DEPTH = 24;
+
+export const DEFAULT_ENVIRONMENT: Record = { NODE_ENV: "development" };
+
+/** Expression evaluations between clock reads while a time budget is set. */
+const CLOCK_CHECK_INTERVAL = 512;
+
+/**
+ * Wires the evaluator modules into one interpreter. Every module receives the
+ * interpreter back so recursion (expressions → calls → statements →
+ * expressions) goes through this object rather than import cycles.
+ */
+export const createInterpreter = (
+ project: Project,
+ linker: Linker,
+ options: InterpreterOptions = {},
+): Interpreter => {
+ const diagnostics: Diagnostic[] = [];
+ const reportedKeys = new Set();
+ const getLocation = (module: ParsedModule, span: Span): SourceLocation => ({
+ filePath: module.filePath,
+ ...module.lineIndex.getPosition(span.start),
+ });
+ let budgetMs: number | null = null;
+ let deadline = Number.POSITIVE_INFINITY;
+ let evaluationsUntilClockCheck = 0;
+ const checkTimeBudget = (): void => {
+ if (budgetMs === null || evaluationsUntilClockCheck-- > 0) return;
+ evaluationsUntilClockCheck = CLOCK_CHECK_INTERVAL;
+ if (performance.now() > deadline) throw new AnalysisTimeoutError(budgetMs);
+ };
+ const interpreter: Interpreter = {
+ project,
+ linker,
+ maxCallDepth: options.maxCallDepth ?? DEFAULT_MAX_CALL_DEPTH,
+ environment: options.environment ?? DEFAULT_ENVIRONMENT,
+ elementType: getReactElementType(project),
+ diagnostics,
+ setTimeBudget: (nextBudgetMs) => {
+ budgetMs = nextBudgetMs;
+ deadline =
+ nextBudgetMs === null ? Number.POSITIVE_INFINITY : performance.now() + nextBudgetMs;
+ evaluationsUntilClockCheck = 0;
+ },
+ moduleScopes: new WeakMap(),
+ valueCache: new Map(),
+ evaluateExpression: (expression, context) => {
+ checkTimeBudget();
+ return evaluateExpression(interpreter, expression, context);
+ },
+ evaluateChain: (chain, span, context) => evaluateChain(interpreter, chain, span, context),
+ evaluateStatements: (statements, context) =>
+ evaluateStatements(interpreter, statements, context),
+ callFunction: (fn, callArguments, context) =>
+ callFunction(interpreter, fn, callArguments, context),
+ getModuleScope: (module) => getModuleScope(interpreter, module),
+ resolveModuleBinding: (module, name) => resolveModuleBinding(interpreter, module, name),
+ getModuleExport: (module, exportedName) => getModuleExport(interpreter, module, exportedName),
+ valueFromSymbol: (symbol) => valueFromSymbol(interpreter, symbol),
+ createModuleContext: (module): EvaluationContext => ({
+ module,
+ scope: getModuleScope(interpreter, module),
+ owner: null,
+ hooks: null,
+ thisValue: null,
+ contexts: EMPTY_CONTEXTS,
+ callDepth: 0,
+ activeCalls: new Map(),
+ undecided: null,
+ }),
+ getLocation,
+ getSource: getSourcePreview,
+ report: (code, message, module, span) => {
+ const key = `${code}\n${message}\n${module.filePath}\n${span?.start ?? ""}`;
+ if (reportedKeys.has(key)) return;
+ reportedKeys.add(key);
+ diagnostics.push({ code, message, location: span ? getLocation(module, span) : null });
+ },
+ };
+ return interpreter;
+};
+
+export * from "./contexts.js";
+export * from "./hooks.js";
+export * from "./interpreter.js";
+export * from "./jsx.js";
+export * from "./mount.js";
+export * from "./naming.js";
+export * from "./scope.js";
+export * from "./values.js";
diff --git a/packages/parser/src/analyze/interpreter.ts b/packages/parser/src/analyze/interpreter.ts
new file mode 100644
index 00000000..3dcec4c0
--- /dev/null
+++ b/packages/parser/src/analyze/interpreter.ts
@@ -0,0 +1,166 @@
+import type { Expression, Span, Statement } from "@oxc-project/types";
+import type { StaticFiber } from "../fiber/types.js";
+import type { LinkedSymbol, Linker } from "../link/linker.js";
+import type { FunctionLike } from "../module/ast.js";
+import type { SourceLocation } from "../module/location.js";
+import type { ParsedModule } from "../module/types.js";
+import type { Project } from "../project/project.js";
+import type { ProvidedContexts } from "./contexts.js";
+import type { HookCall } from "./hooks.js";
+import type { Scope } from "./scope.js";
+import { type FunctionValue, type StaticValue, unknown } from "./values.js";
+
+export interface EvaluationContext {
+ module: ParsedModule;
+ scope: Scope;
+ /** Fiber whose render is executing; stamped on the elements it creates. */
+ owner: StaticFiber | null;
+ /** Hook calls recorded for the component render in progress, if any. */
+ hooks: HookCall[] | null;
+ /** `this` inside class component methods. */
+ thisValue: StaticValue | null;
+ /** Context values provided by the fibers above the render in progress. */
+ contexts: ProvidedContexts;
+ callDepth: number;
+ /** How many times each function on the call stack is executing, to bound recursion. */
+ activeCalls: ReadonlyMap;
+ /** Innermost branch or loop whose direction is not known statically, if any. */
+ undecided: UndecidedFrame | null;
+}
+
+/**
+ * Control flow the analysis could not decide. Side effects performed under
+ * it on values created outside it (`items.push(x)` inside `if (flag)`) must
+ * stay conditional; `depth` tells the two apart.
+ */
+export interface UndecidedFrame {
+ test: string;
+ depth: number;
+}
+
+export const enterUndecided = (
+ context: EvaluationContext,
+ test: string,
+ scope: Scope,
+): EvaluationContext => ({
+ ...context,
+ scope,
+ undecided: { test, depth: getUndecidedDepth(context) + 1 },
+});
+
+export const getUndecidedDepth = (context: EvaluationContext): number =>
+ context.undecided?.depth ?? 0;
+
+/** Whether a side effect happening now on a value created at `createdAtDepth` is conditional. */
+export const isEffectUndecided = (context: EvaluationContext, createdAtDepth: number): boolean =>
+ context.undecided !== null && context.undecided.depth > createdAtDepth;
+
+/**
+ * How a statement list finished. A `partial` completion returned on some
+ * paths only; it becomes a value once the rest of the enclosing sequence has
+ * been evaluated and its result is passed to `complete`. A `throw` leaves
+ * the render path: React shows an error boundary instead of a value.
+ */
+export type Completion =
+ | { kind: "normal" }
+ | { kind: "break" }
+ | { kind: "continue" }
+ | { kind: "throw" }
+ | { kind: "return"; value: StaticValue }
+ | { kind: "partial"; complete: (restValue: StaticValue) => StaticValue };
+
+export type JumpKind = Extract["kind"];
+
+export type DiagnosticCode =
+ | "unresolved-reference"
+ | "unsupported-syntax"
+ | "call-depth"
+ | "external-call";
+
+export interface Diagnostic {
+ code: DiagnosticCode;
+ message: string;
+ location: SourceLocation | null;
+}
+
+export interface InterpreterOptions {
+ /** Nested call depth after which calls evaluate to an unknown value. */
+ maxCallDepth?: number;
+ /**
+ * What `process.env.` reads as, the way a bundler substitutes it.
+ * Defaults to a development build, which is what the harness renders.
+ */
+ environment?: Record;
+}
+
+/** Thrown from evaluation once the render's time budget is spent. */
+export class AnalysisTimeoutError extends Error {
+ constructor(budgetMs: number) {
+ super(`analysis exceeded its ${budgetMs}ms budget`);
+ this.name = "AnalysisTimeoutError";
+ }
+}
+
+export interface Interpreter {
+ project: Project;
+ linker: Linker;
+ maxCallDepth: number;
+ environment: Record;
+ /** `$$typeof` of the elements the project's React creates. */
+ elementType: symbol;
+ diagnostics: Diagnostic[];
+ /** Makes evaluation throw `AnalysisTimeoutError` after `budgetMs`; `null` removes the limit. */
+ setTimeBudget: (budgetMs: number | null) => void;
+ moduleScopes: WeakMap;
+ /** Evaluated export expressions keyed by module path and node offset. */
+ valueCache: Map;
+ evaluateExpression: (expression: Expression, context: EvaluationContext) => StaticValue;
+ /** Resolves an identifier chain such as `["Dialog", "Trigger"]` in a scope. */
+ evaluateChain: (chain: string[], span: Span, context: EvaluationContext) => StaticValue;
+ evaluateStatements: (statements: Statement[], context: EvaluationContext) => Completion;
+ callFunction: (
+ fn: FunctionValue,
+ callArguments: StaticValue[],
+ context: EvaluationContext,
+ ) => StaticValue;
+ getModuleScope: (module: ParsedModule) => Scope;
+ /** Value of a top-level binding, or `null` when the module has no such binding. */
+ resolveModuleBinding: (module: ParsedModule, name: string) => StaticValue | null;
+ getModuleExport: (module: ParsedModule, exportedName: string) => StaticValue;
+ valueFromSymbol: (symbol: LinkedSymbol) => StaticValue;
+ createModuleContext: (module: ParsedModule) => EvaluationContext;
+ getLocation: (module: ParsedModule, span: Span) => SourceLocation;
+ /** Source text of a span, whitespace-collapsed and truncated for display. */
+ getSource: (module: ParsedModule, span: Span) => string;
+ report: (code: DiagnosticCode, message: string, module: ParsedModule, span: Span | null) => void;
+}
+
+export const NORMAL_COMPLETION: Completion = { kind: "normal" };
+export const BREAK_COMPLETION: Completion = { kind: "break" };
+export const CONTINUE_COMPLETION: Completion = { kind: "continue" };
+export const THROW_COMPLETION: Completion = { kind: "throw" };
+
+export const returnCompletion = (value: StaticValue): Completion => ({ kind: "return", value });
+
+/** Value a function body evaluates to; falling off the end yields `undefined`. */
+export const getReturnValue = (completion: Completion, fallthrough: StaticValue): StaticValue => {
+ switch (completion.kind) {
+ case "return":
+ return completion.value;
+ case "partial":
+ return completion.complete(fallthrough);
+ case "throw":
+ return unknown("thrown error");
+ default:
+ return fallthrough;
+ }
+};
+
+export const MAX_SOURCE_PREVIEW_LENGTH = 72;
+
+export const getSourcePreview = (module: ParsedModule, span: Span): string => {
+ const collapsed = module.sourceText.slice(span.start, span.end).replace(/\s+/g, " ").trim();
+ return collapsed.length > MAX_SOURCE_PREVIEW_LENGTH
+ ? `${collapsed.slice(0, MAX_SOURCE_PREVIEW_LENGTH - 1)}…`
+ : collapsed;
+};
diff --git a/packages/parser/src/analyze/jsx-entities.ts b/packages/parser/src/analyze/jsx-entities.ts
new file mode 100644
index 00000000..e9284080
--- /dev/null
+++ b/packages/parser/src/analyze/jsx-entities.ts
@@ -0,0 +1,259 @@
+/**
+ * XHTML 1.0 named character references, the set the JSX specification
+ * (and Babel's parser) decode inside JSX text and attribute strings.
+ */
+export const JSX_NAMED_ENTITIES: Record = {
+ quot: "\u0022",
+ amp: "&",
+ apos: "\u0027",
+ lt: "<",
+ gt: ">",
+ nbsp: "\u00A0",
+ iexcl: "\u00A1",
+ cent: "\u00A2",
+ pound: "\u00A3",
+ curren: "\u00A4",
+ yen: "\u00A5",
+ brvbar: "\u00A6",
+ sect: "\u00A7",
+ uml: "\u00A8",
+ copy: "\u00A9",
+ ordf: "\u00AA",
+ laquo: "\u00AB",
+ not: "\u00AC",
+ shy: "\u00AD",
+ reg: "\u00AE",
+ macr: "\u00AF",
+ deg: "\u00B0",
+ plusmn: "\u00B1",
+ sup2: "\u00B2",
+ sup3: "\u00B3",
+ acute: "\u00B4",
+ micro: "\u00B5",
+ para: "\u00B6",
+ middot: "\u00B7",
+ cedil: "\u00B8",
+ sup1: "\u00B9",
+ ordm: "\u00BA",
+ raquo: "\u00BB",
+ frac14: "\u00BC",
+ frac12: "\u00BD",
+ frac34: "\u00BE",
+ iquest: "\u00BF",
+ Agrave: "\u00C0",
+ Aacute: "\u00C1",
+ Acirc: "\u00C2",
+ Atilde: "\u00C3",
+ Auml: "\u00C4",
+ Aring: "\u00C5",
+ AElig: "\u00C6",
+ Ccedil: "\u00C7",
+ Egrave: "\u00C8",
+ Eacute: "\u00C9",
+ Ecirc: "\u00CA",
+ Euml: "\u00CB",
+ Igrave: "\u00CC",
+ Iacute: "\u00CD",
+ Icirc: "\u00CE",
+ Iuml: "\u00CF",
+ ETH: "\u00D0",
+ Ntilde: "\u00D1",
+ Ograve: "\u00D2",
+ Oacute: "\u00D3",
+ Ocirc: "\u00D4",
+ Otilde: "\u00D5",
+ Ouml: "\u00D6",
+ times: "\u00D7",
+ Oslash: "\u00D8",
+ Ugrave: "\u00D9",
+ Uacute: "\u00DA",
+ Ucirc: "\u00DB",
+ Uuml: "\u00DC",
+ Yacute: "\u00DD",
+ THORN: "\u00DE",
+ szlig: "\u00DF",
+ agrave: "\u00E0",
+ aacute: "\u00E1",
+ acirc: "\u00E2",
+ atilde: "\u00E3",
+ auml: "\u00E4",
+ aring: "\u00E5",
+ aelig: "\u00E6",
+ ccedil: "\u00E7",
+ egrave: "\u00E8",
+ eacute: "\u00E9",
+ ecirc: "\u00EA",
+ euml: "\u00EB",
+ igrave: "\u00EC",
+ iacute: "\u00ED",
+ icirc: "\u00EE",
+ iuml: "\u00EF",
+ eth: "\u00F0",
+ ntilde: "\u00F1",
+ ograve: "\u00F2",
+ oacute: "\u00F3",
+ ocirc: "\u00F4",
+ otilde: "\u00F5",
+ ouml: "\u00F6",
+ divide: "\u00F7",
+ oslash: "\u00F8",
+ ugrave: "\u00F9",
+ uacute: "\u00FA",
+ ucirc: "\u00FB",
+ uuml: "\u00FC",
+ yacute: "\u00FD",
+ thorn: "\u00FE",
+ yuml: "\u00FF",
+ OElig: "\u0152",
+ oelig: "\u0153",
+ Scaron: "\u0160",
+ scaron: "\u0161",
+ Yuml: "\u0178",
+ fnof: "\u0192",
+ circ: "\u02C6",
+ tilde: "\u02DC",
+ Alpha: "\u0391",
+ Beta: "\u0392",
+ Gamma: "\u0393",
+ Delta: "\u0394",
+ Epsilon: "\u0395",
+ Zeta: "\u0396",
+ Eta: "\u0397",
+ Theta: "\u0398",
+ Iota: "\u0399",
+ Kappa: "\u039A",
+ Lambda: "\u039B",
+ Mu: "\u039C",
+ Nu: "\u039D",
+ Xi: "\u039E",
+ Omicron: "\u039F",
+ Pi: "\u03A0",
+ Rho: "\u03A1",
+ Sigma: "\u03A3",
+ Tau: "\u03A4",
+ Upsilon: "\u03A5",
+ Phi: "\u03A6",
+ Chi: "\u03A7",
+ Psi: "\u03A8",
+ Omega: "\u03A9",
+ alpha: "\u03B1",
+ beta: "\u03B2",
+ gamma: "\u03B3",
+ delta: "\u03B4",
+ epsilon: "\u03B5",
+ zeta: "\u03B6",
+ eta: "\u03B7",
+ theta: "\u03B8",
+ iota: "\u03B9",
+ kappa: "\u03BA",
+ lambda: "\u03BB",
+ mu: "\u03BC",
+ nu: "\u03BD",
+ xi: "\u03BE",
+ omicron: "\u03BF",
+ pi: "\u03C0",
+ rho: "\u03C1",
+ sigmaf: "\u03C2",
+ sigma: "\u03C3",
+ tau: "\u03C4",
+ upsilon: "\u03C5",
+ phi: "\u03C6",
+ chi: "\u03C7",
+ psi: "\u03C8",
+ omega: "\u03C9",
+ thetasym: "\u03D1",
+ upsih: "\u03D2",
+ piv: "\u03D6",
+ ensp: "\u2002",
+ emsp: "\u2003",
+ thinsp: "\u2009",
+ zwnj: "\u200C",
+ zwj: "\u200D",
+ lrm: "\u200E",
+ rlm: "\u200F",
+ ndash: "\u2013",
+ mdash: "\u2014",
+ lsquo: "\u2018",
+ rsquo: "\u2019",
+ sbquo: "\u201A",
+ ldquo: "\u201C",
+ rdquo: "\u201D",
+ bdquo: "\u201E",
+ dagger: "\u2020",
+ Dagger: "\u2021",
+ bull: "\u2022",
+ hellip: "\u2026",
+ permil: "\u2030",
+ prime: "\u2032",
+ Prime: "\u2033",
+ lsaquo: "\u2039",
+ rsaquo: "\u203A",
+ oline: "\u203E",
+ frasl: "\u2044",
+ euro: "\u20AC",
+ image: "\u2111",
+ weierp: "\u2118",
+ real: "\u211C",
+ trade: "\u2122",
+ alefsym: "\u2135",
+ larr: "\u2190",
+ uarr: "\u2191",
+ rarr: "\u2192",
+ darr: "\u2193",
+ harr: "\u2194",
+ crarr: "\u21B5",
+ lArr: "\u21D0",
+ uArr: "\u21D1",
+ rArr: "\u21D2",
+ dArr: "\u21D3",
+ hArr: "\u21D4",
+ forall: "\u2200",
+ part: "\u2202",
+ exist: "\u2203",
+ empty: "\u2205",
+ nabla: "\u2207",
+ isin: "\u2208",
+ notin: "\u2209",
+ ni: "\u220B",
+ prod: "\u220F",
+ sum: "\u2211",
+ minus: "\u2212",
+ lowast: "\u2217",
+ radic: "\u221A",
+ prop: "\u221D",
+ infin: "\u221E",
+ ang: "\u2220",
+ and: "\u2227",
+ or: "\u2228",
+ cap: "\u2229",
+ cup: "\u222A",
+ int: "\u222B",
+ there4: "\u2234",
+ sim: "\u223C",
+ cong: "\u2245",
+ asymp: "\u2248",
+ ne: "\u2260",
+ equiv: "\u2261",
+ le: "\u2264",
+ ge: "\u2265",
+ sub: "\u2282",
+ sup: "\u2283",
+ nsub: "\u2284",
+ sube: "\u2286",
+ supe: "\u2287",
+ oplus: "\u2295",
+ otimes: "\u2297",
+ perp: "\u22A5",
+ sdot: "\u22C5",
+ lceil: "\u2308",
+ rceil: "\u2309",
+ lfloor: "\u230A",
+ rfloor: "\u230B",
+ lang: "\u2329",
+ rang: "\u232A",
+ loz: "\u25CA",
+ spades: "\u2660",
+ clubs: "\u2663",
+ hearts: "\u2665",
+ diams: "\u2666",
+};
diff --git a/packages/parser/src/analyze/jsx.ts b/packages/parser/src/analyze/jsx.ts
new file mode 100644
index 00000000..60856363
--- /dev/null
+++ b/packages/parser/src/analyze/jsx.ts
@@ -0,0 +1,214 @@
+import type {
+ JSXAttributeItem,
+ JSXAttributeValue,
+ JSXChild,
+ JSXElement,
+ JSXElementName,
+ JSXFragment,
+ Span,
+} from "@oxc-project/types";
+import { getJsxNameChain } from "../module/ast.js";
+import type { EvaluationContext, Interpreter } from "./interpreter.js";
+import { JSX_NAMED_ENTITIES } from "./jsx-entities.js";
+import {
+ array,
+ builtin,
+ type ElementValue,
+ literal,
+ mergeObjects,
+ object,
+ type ObjectValue,
+ type StaticValue,
+ TRUE,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+const ENTITY_PATTERN = /&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g;
+
+/** Decodes the XHTML entities the JSX grammar permits in text and attributes. */
+export const decodeJsxEntities = (raw: string): string =>
+ raw.replace(ENTITY_PATTERN, (match, body: string) => {
+ if (body[0] === "#") {
+ const codePoint =
+ body[1] === "x" || body[1] === "X"
+ ? Number.parseInt(body.slice(2), 16)
+ : Number(body.slice(1));
+ return Number.isFinite(codePoint) && codePoint <= 0x10ffff
+ ? String.fromCodePoint(codePoint)
+ : match;
+ }
+ return JSX_NAMED_ENTITIES[body] ?? match;
+ });
+
+/**
+ * Whitespace rules applied by every JSX compiler (Babel's
+ * `cleanJSXElementLiteralChild`): lines are trimmed at newline boundaries,
+ * tabs become spaces, blank lines vanish and remaining lines are joined
+ * with a single space. Returns `null` when nothing is left to render.
+ */
+export const cleanJsxText = (rawText: string): string | null => {
+ const lines = decodeJsxEntities(rawText).split(/\r\n|\n|\r/);
+ let lastNonEmptyLine = 0;
+ lines.forEach((line, index) => {
+ if (/[^ \t]/.test(line)) lastNonEmptyLine = index;
+ });
+ let result = "";
+ lines.forEach((line, index) => {
+ let trimmed = line.replace(/\t/g, " ");
+ if (index !== 0) trimmed = trimmed.replace(/^ +/, "");
+ if (index !== lines.length - 1) trimmed = trimmed.replace(/ +$/, "");
+ if (!trimmed) return;
+ result += index === lastNonEmptyLine ? trimmed : `${trimmed} `;
+ });
+ return result || null;
+};
+
+const isHostTagName = (name: string): boolean => {
+ const firstCharacter = name.charCodeAt(0);
+ return (firstCharacter >= 97 && firstCharacter <= 122) || name.includes("-");
+};
+
+/**
+ * Lowercase and dashed tags are host elements; everything else is looked up
+ * as a value, including member chains such as `Dialog.Trigger`.
+ */
+const evaluateElementType = (
+ interpreter: Interpreter,
+ name: JSXElementName,
+ context: EvaluationContext,
+): StaticValue => {
+ const chain = getJsxNameChain(name);
+ if (!chain) return unknown("jsx element name");
+ if (name.type === "JSXNamespacedName") return literal(chain[0]);
+ if (chain.length === 1 && isHostTagName(chain[0])) return literal(chain[0]);
+ return interpreter.evaluateChain(chain, name, context);
+};
+
+const evaluateAttributes = (
+ interpreter: Interpreter,
+ attributes: JSXAttributeItem[],
+ context: EvaluationContext,
+): { props: ObjectValue; key: StaticValue | null } => {
+ const props = object();
+ let key: StaticValue | null = null;
+ for (const attribute of attributes) {
+ if (attribute.type === "JSXSpreadAttribute") {
+ const spread = interpreter.evaluateExpression(attribute.argument, context);
+ if (spread.kind === "object") {
+ mergeObjects(props, spread);
+ const spreadKey = spread.properties.get("key");
+ if (spreadKey && key === null) key = spreadKey;
+ } else {
+ props.hasUnknownSpread = true;
+ }
+ continue;
+ }
+ const attributeName =
+ attribute.name.type === "JSXIdentifier"
+ ? attribute.name.name
+ : `${attribute.name.namespace.name}:${attribute.name.name.name}`;
+ const value = evaluateAttributeValue(interpreter, attribute.value, context);
+ if (attributeName === "key") key = value;
+ else props.properties.set(attributeName, value);
+ }
+ props.properties.delete("key");
+ return { props, key };
+};
+
+const evaluateAttributeValue = (
+ interpreter: Interpreter,
+ value: JSXAttributeValue | null,
+ context: EvaluationContext,
+): StaticValue => {
+ if (value === null) return TRUE;
+ switch (value.type) {
+ case "Literal":
+ return literal(decodeJsxEntities(value.value));
+ case "JSXExpressionContainer":
+ return value.expression.type === "JSXEmptyExpression"
+ ? UNDEFINED
+ : interpreter.evaluateExpression(value.expression, context);
+ case "JSXElement":
+ return evaluateJsxElement(interpreter, value, context);
+ case "JSXFragment":
+ return evaluateJsxFragment(interpreter, value, context);
+ }
+};
+
+/**
+ * Builds the `children` prop the way the JSX transform does: text is
+ * whitespace-cleaned, empty expressions are dropped, a single child stays
+ * unwrapped and several children form an array.
+ */
+export const evaluateJsxChildren = (
+ interpreter: Interpreter,
+ children: JSXChild[],
+ context: EvaluationContext,
+): StaticValue | null => {
+ const values: StaticValue[] = [];
+ for (const child of children) {
+ switch (child.type) {
+ case "JSXText": {
+ const cleaned = cleanJsxText(child.value);
+ if (cleaned !== null) values.push(literal(cleaned));
+ break;
+ }
+ case "JSXExpressionContainer":
+ if (child.expression.type !== "JSXEmptyExpression") {
+ values.push(interpreter.evaluateExpression(child.expression, context));
+ }
+ break;
+ case "JSXSpreadChild":
+ values.push(interpreter.evaluateExpression(child.expression, context));
+ break;
+ case "JSXElement":
+ values.push(evaluateJsxElement(interpreter, child, context));
+ break;
+ case "JSXFragment":
+ values.push(evaluateJsxFragment(interpreter, child, context));
+ break;
+ }
+ }
+ if (values.length === 0) return null;
+ return values.length === 1 ? values[0] : array(values);
+};
+
+export const createElementValue = (
+ interpreter: Interpreter,
+ type: StaticValue,
+ props: ObjectValue,
+ key: StaticValue | null,
+ span: Span,
+ context: EvaluationContext,
+): ElementValue => ({
+ kind: "element",
+ type,
+ key,
+ props,
+ location: interpreter.getLocation(context.module, span),
+ owner: context.owner,
+});
+
+export const evaluateJsxElement = (
+ interpreter: Interpreter,
+ node: JSXElement,
+ context: EvaluationContext,
+): ElementValue => {
+ const type = evaluateElementType(interpreter, node.openingElement.name, context);
+ const { props, key } = evaluateAttributes(interpreter, node.openingElement.attributes, context);
+ const children = evaluateJsxChildren(interpreter, node.children, context);
+ if (children !== null) props.properties.set("children", children);
+ return createElementValue(interpreter, type, props, key, node, context);
+};
+
+export const evaluateJsxFragment = (
+ interpreter: Interpreter,
+ node: JSXFragment,
+ context: EvaluationContext,
+): ElementValue => {
+ const props = object();
+ const children = evaluateJsxChildren(interpreter, node.children, context);
+ if (children !== null) props.properties.set("children", children);
+ return createElementValue(interpreter, builtin("Fragment"), props, null, node, context);
+};
diff --git a/packages/parser/src/analyze/mount.ts b/packages/parser/src/analyze/mount.ts
new file mode 100644
index 00000000..b1fb38eb
--- /dev/null
+++ b/packages/parser/src/analyze/mount.ts
@@ -0,0 +1,140 @@
+import type { Argument, CallExpression, Expression } from "@oxc-project/types";
+import { getReactApiReference } from "../link/react-api.js";
+import {
+ getMemberChain,
+ isCallExpression,
+ isNodeOfType,
+ isStaticMemberExpression,
+ unwrapExpression,
+ walk,
+} from "../module/ast.js";
+import type { SourceLocation } from "../module/location.js";
+import type { ParsedModule } from "../module/types.js";
+import type { EvaluationContext, Interpreter } from "./interpreter.js";
+import type { StaticValue } from "./values.js";
+
+/**
+ * The react-dom call that hands an element tree to a root: `createRoot(el)
+ * .render(x)`, `hydrateRoot(el, x)` or the legacy `ReactDOM.render(x, el)`.
+ */
+export type MountApi = "createRoot" | "hydrateRoot" | "render";
+
+export interface MountPoint {
+ api: MountApi;
+ /** The element tree the root renders. */
+ element: StaticValue;
+ location: SourceLocation;
+}
+
+const ROOT_FACTORIES: ReadonlySet = new Set(["createRoot", "hydrateRoot"]);
+
+/**
+ * The react-dom API a callee names, if any. Only chains rooted in a
+ * module-level binding qualify: react-dom arrives through imports, and
+ * evaluating locals of nested functions in module scope would resolve
+ * nothing.
+ */
+const getReactDomApi = (
+ interpreter: Interpreter,
+ callee: Expression,
+ module: ParsedModule,
+ context: EvaluationContext,
+): string | null => {
+ const chain = getMemberChain(callee);
+ if (chain === null || !module.bindings.has(chain[0])) return null;
+ const value = interpreter.evaluateChain(chain, callee, context);
+ if (value.kind !== "external") return null;
+ const reference = getReactApiReference(value);
+ return reference?.source === "react-dom" ? reference.api : null;
+};
+
+const isRootFactoryCall = (
+ interpreter: Interpreter,
+ expression: Expression,
+ module: ParsedModule,
+ context: EvaluationContext,
+): boolean => {
+ const unwrapped = unwrapExpression(expression);
+ if (!isCallExpression(unwrapped)) return false;
+ const api = getReactDomApi(interpreter, unwrapped.callee, module, context);
+ return api !== null && ROOT_FACTORIES.has(api);
+};
+
+/**
+ * Whether `expression` is a root: a `createRoot()` call or a module-level
+ * binding initialised with one (`const root = createRoot(el)`).
+ */
+const isRoot = (
+ interpreter: Interpreter,
+ expression: Expression,
+ module: ParsedModule,
+ context: EvaluationContext,
+): boolean => {
+ const unwrapped = unwrapExpression(expression);
+ if (isRootFactoryCall(interpreter, unwrapped, module, context)) return true;
+ if (unwrapped.type !== "Identifier") return false;
+ const binding = module.bindings.get(unwrapped.name);
+ if (binding?.kind !== "declaration" || binding.node.type !== "VariableDeclarator") return false;
+ return (
+ binding.node.init !== null && isRootFactoryCall(interpreter, binding.node.init, module, context)
+ );
+};
+
+const getElementArgument = (argument: Argument | undefined): Expression | null =>
+ argument === undefined || argument.type === "SpreadElement" ? null : argument;
+
+const classifyMountCall = (
+ interpreter: Interpreter,
+ call: CallExpression,
+ module: ParsedModule,
+ context: EvaluationContext,
+): { api: MountApi; element: Expression } | null => {
+ const callee = unwrapExpression(call.callee);
+ if (
+ isStaticMemberExpression(callee) &&
+ callee.property.name === "render" &&
+ isRoot(interpreter, callee.object, module, context)
+ ) {
+ const element = getElementArgument(call.arguments[0]);
+ return element ? { api: "createRoot", element } : null;
+ }
+ const api = getReactDomApi(interpreter, callee, module, context);
+ if (api === "hydrateRoot") {
+ const element = getElementArgument(call.arguments[1]);
+ return element ? { api, element } : null;
+ }
+ if (api === "render") {
+ const element = getElementArgument(call.arguments[0]);
+ return element ? { api, element } : null;
+ }
+ return null;
+};
+
+const collectCalls = (module: ParsedModule): CallExpression[] => {
+ const calls: CallExpression[] = [];
+ walk(module.program, (node) => {
+ if (isNodeOfType(node, "CallExpression")) calls.push(node);
+ });
+ return calls;
+};
+
+/**
+ * Finds the places a module hands elements to react-dom. Elements are
+ * evaluated in module scope, which is where `createRoot(el).render()`
+ * lives in practice; a mount inside a function still resolves the
+ * module-level components it references.
+ */
+export const findMountPoints = (interpreter: Interpreter, module: ParsedModule): MountPoint[] => {
+ const context = interpreter.createModuleContext(module);
+ const mounts: MountPoint[] = [];
+ for (const call of collectCalls(module)) {
+ const mount = classifyMountCall(interpreter, call, module, context);
+ if (!mount) continue;
+ mounts.push({
+ api: mount.api,
+ element: interpreter.evaluateExpression(mount.element, context),
+ location: interpreter.getLocation(module, call),
+ });
+ }
+ return mounts;
+};
diff --git a/packages/parser/src/analyze/naming.ts b/packages/parser/src/analyze/naming.ts
new file mode 100644
index 00000000..a2803847
--- /dev/null
+++ b/packages/parser/src/analyze/naming.ts
@@ -0,0 +1,37 @@
+import type { Expression } from "@oxc-project/types";
+import { type FunctionLike, getMemberChain, isNodeOfType, walk } from "../module/ast.js";
+
+/** Mirrors React Compiler's `isComponentName`: components start uppercase. */
+export const isComponentName = (name: string): boolean => /^[A-Z]/.test(name);
+
+/** Mirrors React Compiler's `isHookName`: `use` followed by uppercase or digit. */
+export const isHookName = (name: string): boolean => /^use[A-Z0-9]/.test(name);
+
+/**
+ * Mirrors React Compiler's `isHook`: a hook identifier or a member access
+ * `Namespace.useThing` whose object is PascalCase.
+ */
+export const isHookCallee = (callee: Expression): boolean => {
+ const chain = getMemberChain(callee);
+ if (!chain) return false;
+ if (chain.length === 1) return isHookName(chain[0]);
+ if (chain.length === 2) return isComponentName(chain[0]) && isHookName(chain[1]);
+ return false;
+};
+
+/**
+ * Mirrors React Compiler's `callsHooksOrCreatesJsx`, which keeps capitalized
+ * functions that are not components (route handlers such as `GET`, builders
+ * like `Schema`) from being treated as one. Nested functions count, so a
+ * component rendering only through `items.map(() => )` still qualifies.
+ */
+export const callsHooksOrCreatesJsx = (fn: FunctionLike): boolean => {
+ let isFound = false;
+ walk(fn, (node) => {
+ if (isFound) return false;
+ if (node.type === "JSXElement" || node.type === "JSXFragment") isFound = true;
+ else if (isNodeOfType(node, "CallExpression") && isHookCallee(node.callee)) isFound = true;
+ return !isFound;
+ });
+ return isFound;
+};
diff --git a/packages/parser/src/analyze/narrowing.ts b/packages/parser/src/analyze/narrowing.ts
new file mode 100644
index 00000000..dff4652e
--- /dev/null
+++ b/packages/parser/src/analyze/narrowing.ts
@@ -0,0 +1,267 @@
+import type { Expression } from "@oxc-project/types";
+import { getMemberLinks, unwrapExpression } from "../module/ast.js";
+import { equalsPrimitive } from "./operators.js";
+import {
+ createScope,
+ declareVariable,
+ isModuleScope,
+ lookupVariable,
+ type Scope,
+} from "./scope.js";
+import {
+ assumeTest,
+ conditional,
+ getTruthiness,
+ isNullishValue,
+ type Primitive,
+ type StaticValue,
+ UNDEFINED,
+} from "./values.js";
+
+/** Whether one arm of a value can exist on a path; `null` when the arm cannot tell. */
+export type ArmFilter = (arm: StaticValue) => boolean | null;
+
+/** A variable, or a property path below one, and the arms of its value a path keeps. */
+export interface PathNarrowing {
+ kind: "path";
+ path: string[];
+ keep: ArmFilter;
+ /** Read through `?.`, so a nullish prefix yields `undefined` instead of failing. */
+ isOptional: boolean;
+}
+
+/** The outcome a path fixes for a test, so every value branching on that test loses the other arm. */
+export interface TestAssumption {
+ kind: "assumption";
+ test: string;
+ outcome: boolean;
+}
+
+export type Narrowing = PathNarrowing | TestAssumption;
+
+/** The source text of a test, in the form conditional values quote it. */
+export type DescribeTest = (test: Expression) => string;
+
+const negate =
+ (keep: ArmFilter): ArmFilter =>
+ (arm) => {
+ const verdict = keep(arm);
+ return verdict === null ? null : !verdict;
+ };
+
+const either =
+ (filters: ArmFilter[]): ArmFilter =>
+ (arm) => {
+ const verdicts = filters.map((filter) => filter(arm));
+ if (verdicts.includes(true)) return true;
+ return verdicts.includes(null) ? null : false;
+ };
+
+export const keepTruthy: ArmFilter = (arm) => getTruthiness(arm);
+export const keepFalsy = negate(keepTruthy);
+
+const keepEqualTo =
+ (expected: Primitive, isLoose: boolean): ArmFilter =>
+ (arm) =>
+ equalsPrimitive(arm, expected, !isLoose);
+
+export const keepNullish = keepEqualTo(undefined, true);
+export const keepNonNullish = negate(keepNullish);
+
+interface ComparedPrimitive {
+ value: Primitive;
+}
+
+/** The primitive an operand compares against, when it is written as a literal or `undefined`. */
+const getComparedPrimitive = (expression: Expression): ComparedPrimitive | null => {
+ const unwrapped = unwrapExpression(expression);
+ if (unwrapped.type === "Identifier" && unwrapped.name === "undefined")
+ return { value: undefined };
+ if (unwrapped.type === "Literal" && typeof unwrapped.value !== "object") {
+ return { value: unwrapped.value };
+ }
+ return unwrapped.type === "Literal" && unwrapped.value === null ? { value: null } : null;
+};
+
+/** The variable an access or call spine starts from: `a` in `a[k].b()`. */
+const getSpineRoot = (expression: Expression): string | null => {
+ let current = unwrapExpression(expression);
+ while (current.type === "MemberExpression" || current.type === "CallExpression") {
+ current = unwrapExpression(
+ current.type === "MemberExpression" ? current.object : current.callee,
+ );
+ }
+ return current.type === "Identifier" ? current.name : null;
+};
+
+/**
+ * Records what `keep` holding for `expression` says. A static member path
+ * is narrowed in place; any other spine still tells that its root was
+ * dereferenced when the outcome rules out a short-circuited `undefined`.
+ */
+const narrowExpression = (expression: Expression, keep: ArmFilter, into: Narrowing[]): void => {
+ const links = getMemberLinks(expression);
+ if (links && links[0].name !== "this") {
+ into.push({
+ kind: "path",
+ path: links.map((link) => link.name),
+ keep,
+ isOptional: links.some((link) => link.isOptional),
+ });
+ return;
+ }
+ const root = keep(UNDEFINED) === false ? getSpineRoot(expression) : null;
+ if (root !== null) {
+ into.push({ kind: "path", path: [root], keep: keepNonNullish, isOptional: false });
+ }
+};
+
+/**
+ * What a test's `outcome` says: values that branched on the same test lose
+ * their other arm, and the variables it reads are refined for `if (x)`,
+ * `!x.y`, `x != null`, `x?.y === "a"`, and conjunctions or disjunctions
+ * whose outcome decides every operand.
+ */
+export const collectNarrowings = (
+ test: Expression,
+ outcome: boolean,
+ describe: DescribeTest,
+ into: Narrowing[] = [],
+): Narrowing[] => {
+ const expression = unwrapExpression(test);
+ if (expression.type === "UnaryExpression" && expression.operator === "!") {
+ return collectNarrowings(expression.argument, !outcome, describe, into);
+ }
+ into.push({ kind: "assumption", test: describe(test), outcome });
+ switch (expression.type) {
+ case "Identifier":
+ case "MemberExpression":
+ case "CallExpression":
+ case "ChainExpression":
+ narrowExpression(expression, outcome ? keepTruthy : keepFalsy, into);
+ break;
+ case "LogicalExpression":
+ if ((expression.operator === "&&") === outcome && expression.operator !== "??") {
+ collectNarrowings(expression.left, outcome, describe, into);
+ collectNarrowings(expression.right, outcome, describe, into);
+ }
+ break;
+ case "BinaryExpression": {
+ const isEquality = expression.operator === "==" || expression.operator === "===";
+ const isInequality = expression.operator === "!=" || expression.operator === "!==";
+ if (!isEquality && !isInequality) break;
+ const isLoose = expression.operator === "==" || expression.operator === "!=";
+ const operands: [Expression, Expression][] = [
+ [expression.left, expression.right],
+ [expression.right, expression.left],
+ ];
+ for (const [side, other] of operands) {
+ const compared = getComparedPrimitive(other);
+ if (compared === null) continue;
+ const keep = keepEqualTo(compared.value, isLoose);
+ narrowExpression(side, isEquality === outcome ? keep : negate(keep), into);
+ }
+ break;
+ }
+ }
+ return into;
+};
+
+/** `discriminant === `, or its negation once the cases did not match. */
+export const collectCaseNarrowings = (
+ discriminant: Expression,
+ tests: Expression[],
+ isMatched: boolean,
+): Narrowing[] => {
+ const filters: ArmFilter[] = [];
+ for (const test of tests) {
+ const compared = getComparedPrimitive(test);
+ if (compared === null) return [];
+ filters.push(keepEqualTo(compared.value, false));
+ }
+ if (filters.length === 0) return [];
+ const into: Narrowing[] = [];
+ const keep = either(filters);
+ narrowExpression(discriminant, isMatched ? keep : negate(keep), into);
+ return into;
+};
+
+/** Drops the arms of a branching value that `keep` rules out; `null` when none remain. */
+export const keepArms = (value: StaticValue, keep: ArmFilter): StaticValue | null =>
+ narrowPath(value, [], keep, false);
+
+/**
+ * Drops the arms of a value a path rules out at the end of `path`, walking
+ * through objects and the arms of conditionals; `null` when nothing remains.
+ * A nullish value met before the end of the path cannot be read further, so
+ * it survives only where an optional chain would have yielded `undefined`.
+ */
+const narrowPath = (
+ value: StaticValue,
+ path: string[],
+ keep: ArmFilter,
+ isOptional: boolean,
+): StaticValue | null => {
+ if (value.kind === "conditional") {
+ const whenTrue = narrowPath(value.whenTrue, path, keep, isOptional);
+ const whenFalse = narrowPath(value.whenFalse, path, keep, isOptional);
+ if (whenTrue === null) return whenFalse;
+ if (whenFalse === null) return whenTrue;
+ return whenTrue === value.whenTrue && whenFalse === value.whenFalse
+ ? value
+ : conditional(value.test, whenTrue, whenFalse);
+ }
+ if (path.length === 0) return keep(value) === false ? null : value;
+ if (isNullishValue(value)) return isOptional && keep(UNDEFINED) !== false ? value : null;
+ if (value.kind !== "object") return value;
+ const [key, ...rest] = path;
+ const property = value.properties.get(key);
+ if (property === undefined) return value;
+ const refined = narrowPath(property, rest, keep, isOptional);
+ if (refined === null) return null;
+ if (refined === property) return value;
+ return { ...value, properties: new Map(value.properties).set(key, refined) };
+};
+
+/** Names bound between `scope` and the module scope, whose bindings are too many to revisit. */
+const getLocalNames = (scope: Scope): Set => {
+ const names = new Set();
+ for (
+ let current: Scope | null = scope;
+ current && !isModuleScope(current);
+ current = current.parent
+ )
+ for (const name of current.variables.keys()) names.add(name);
+ return names;
+};
+
+/**
+ * A scope for a path on which `narrowings` hold: variables whose values
+ * branch lose the arms the path rules out. Writes go through to the
+ * declaring scope, and a rewritten variable forgets what was assumed.
+ */
+export const narrowScope = (scope: Scope, narrowings: Narrowing[]): Scope => {
+ let narrowed: Scope | null = null;
+ let localNames: Set | null = null;
+ const refine = (name: string, refined: StaticValue | null, current: StaticValue): void => {
+ if (refined === null || refined === current) return;
+ narrowed ??= createScope(scope, "narrowing");
+ declareVariable(narrowed, name, refined);
+ };
+ for (const narrowing of narrowings) {
+ if (narrowing.kind === "assumption") {
+ localNames ??= getLocalNames(scope);
+ for (const name of localNames) {
+ const current = lookupVariable(narrowed ?? scope, name);
+ if (current === undefined) continue;
+ refine(name, assumeTest(current, narrowing.test, narrowing.outcome), current);
+ }
+ continue;
+ }
+ const [name, ...propertyPath] = narrowing.path;
+ const current = lookupVariable(narrowed ?? scope, name);
+ if (current === undefined) continue;
+ refine(name, narrowPath(current, propertyPath, narrowing.keep, narrowing.isOptional), current);
+ }
+ return narrowed ?? scope;
+};
diff --git a/packages/parser/src/analyze/operators.ts b/packages/parser/src/analyze/operators.ts
new file mode 100644
index 00000000..f0a3c11b
--- /dev/null
+++ b/packages/parser/src/analyze/operators.ts
@@ -0,0 +1,242 @@
+import type { AssignmentOperator, BinaryOperator, UnaryOperator } from "@oxc-project/types";
+import { hasProperty } from "./access.js";
+import {
+ type ComponentDefinition,
+ countArms,
+ getTruthiness,
+ isNullish,
+ literal,
+ mapConditional,
+ type Primitive,
+ SELECTION_LIMIT,
+ type StaticValue,
+ text,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+const COMPOUND_ASSIGNMENT_OPERATORS: Partial> = {
+ "+=": "+",
+ "-=": "-",
+ "*=": "*",
+ "/=": "/",
+ "%=": "%",
+ "**=": "**",
+ "<<=": "<<",
+ ">>=": ">>",
+ ">>>=": ">>>",
+ "|=": "|",
+ "^=": "^",
+ "&=": "&",
+};
+
+/** The binary operator a compound assignment applies, or `null` for `=` and logical assignments. */
+export const getBinaryOperator = (operator: AssignmentOperator): BinaryOperator | null =>
+ COMPOUND_ASSIGNMENT_OPERATORS[operator] ?? null;
+
+/** `typeof` of what each component definition is at runtime: classes are functions, wrappers objects, Fragment and friends symbols. */
+const COMPONENT_TYPE_NAMES: Record = {
+ class: "function",
+ memo: "object",
+ forwardRef: "object",
+ lazy: "object",
+ context: "object",
+ builtin: "symbol",
+};
+
+const typeOfValue = (value: StaticValue): string | null => {
+ switch (value.kind) {
+ case "literal":
+ return typeof value.value;
+ case "text":
+ return "string";
+ case "regexp":
+ case "array":
+ case "list":
+ case "object":
+ case "element":
+ case "namespace":
+ return "object";
+ case "function":
+ return "function";
+ case "component":
+ return COMPONENT_TYPE_NAMES[value.definition.kind];
+ case "global":
+ return value.typeName;
+ default:
+ return null;
+ }
+};
+
+export const applyUnaryOperator = (
+ operator: UnaryOperator,
+ operand: StaticValue,
+ description: string,
+): StaticValue => {
+ switch (operator) {
+ case "!": {
+ const truthiness = getTruthiness(operand);
+ return truthiness === null ? unknown(description) : literal(!truthiness);
+ }
+ case "typeof": {
+ const typeName = typeOfValue(operand);
+ return typeName === null ? unknown(description) : literal(typeName);
+ }
+ case "void":
+ return UNDEFINED;
+ case "-":
+ case "+":
+ case "~":
+ if (operand.kind === "literal" && typeof operand.value === "number") {
+ const numeric = operand.value;
+ return literal(operator === "-" ? -numeric : operator === "+" ? numeric : ~numeric);
+ }
+ return unknown(description);
+ case "delete":
+ return unknown(description);
+ }
+};
+
+const foldPrimitives = (
+ operator: BinaryOperator,
+ left: Primitive,
+ right: Primitive,
+): Primitive | null => {
+ switch (operator) {
+ case "===":
+ return left === right;
+ case "!==":
+ return left !== right;
+ case "==":
+ // oxlint-disable-next-line eqeqeq -- folds the source's own loose comparison
+ return left == right;
+ case "!=":
+ // oxlint-disable-next-line eqeqeq -- folds the source's own loose comparison
+ return left != right;
+ case "+":
+ if (typeof left === "string" || typeof right === "string") {
+ return `${String(left)}${String(right)}`;
+ }
+ if (typeof left === "number" && typeof right === "number") return left + right;
+ return null;
+ }
+ if (typeof left !== "number" || typeof right !== "number") return null;
+ switch (operator) {
+ case "-":
+ return left - right;
+ case "*":
+ return left * right;
+ case "/":
+ return left / right;
+ case "%":
+ return left % right;
+ case "**":
+ return left ** right;
+ case "<":
+ return left < right;
+ case "<=":
+ return left <= right;
+ case ">":
+ return left > right;
+ case ">=":
+ return left >= right;
+ default:
+ return null;
+ }
+};
+
+const REFERENCE_KINDS = new Set([
+ "function",
+ "component",
+ "object",
+ "array",
+ "element",
+ "namespace",
+]);
+
+const OBJECT_KINDS = new Set([...REFERENCE_KINDS, "regexp", "list", "global"]);
+
+const EQUALITY_OPERATORS = new Set(["===", "==", "!==", "!="]);
+
+/**
+ * Whether `value` equals `primitive`, when decidable. A value known only by
+ * shape still decides some comparisons: an object never strictly equals a
+ * primitive and is never loosely nullish, though it may coerce to a string,
+ * number or boolean; a string is never nullish and only strictly equals
+ * another string.
+ */
+export const equalsPrimitive = (
+ value: StaticValue,
+ primitive: Primitive,
+ isStrict: boolean,
+): boolean | null => {
+ if (value.kind === "literal") {
+ // oxlint-disable-next-line eqeqeq -- folds the source's own loose comparison
+ return isStrict ? value.value === primitive : value.value == primitive;
+ }
+ if (isNullish(primitive)) {
+ return OBJECT_KINDS.has(value.kind) || value.kind === "text" ? false : null;
+ }
+ if (!isStrict) return null;
+ if (OBJECT_KINDS.has(value.kind)) return false;
+ return value.kind === "text" && typeof primitive !== "string" ? false : null;
+};
+
+/** `left` and `right` are equal, when their shapes decide it; `null` otherwise. */
+const decideEquality = (
+ left: StaticValue,
+ right: StaticValue,
+ isStrict: boolean,
+): boolean | null => {
+ /** One static value stands for one runtime object; distinct values may still be the same object. */
+ if (left === right && REFERENCE_KINDS.has(left.kind)) return true;
+ if (left.kind === "global" && right.kind === "global") {
+ return left.chain.join(".") === right.chain.join(".");
+ }
+ if (right.kind === "literal") return equalsPrimitive(left, right.value, isStrict);
+ if (left.kind === "literal") return equalsPrimitive(right, left.value, isStrict);
+ return null;
+};
+
+const applyToArms = (
+ operator: BinaryOperator,
+ left: StaticValue,
+ right: StaticValue,
+ description: string,
+): StaticValue => {
+ if (left.kind === "literal" && right.kind === "literal") {
+ const folded = foldPrimitives(operator, left.value, right.value);
+ if (folded !== null) return literal(folded);
+ }
+ if (EQUALITY_OPERATORS.has(operator)) {
+ const isEqual = decideEquality(left, right, operator === "===" || operator === "!==");
+ if (isEqual !== null) return literal(operator.startsWith("!") ? !isEqual : isEqual);
+ }
+ if (operator === "in" && left.kind === "literal" && typeof left.value !== "symbol") {
+ const isPresent = hasProperty(right, String(left.value));
+ if (isPresent !== null) return literal(isPresent);
+ }
+ if (operator === "+") {
+ const isStringLike = (value: StaticValue): boolean =>
+ value.kind === "text" || (value.kind === "literal" && typeof value.value === "string");
+ if (isStringLike(left) || isStringLike(right)) return text(description);
+ }
+ return unknown(description);
+};
+
+/**
+ * Binary operators apply to each arm of a conditional operand, so a test on
+ * `show ? a : b` decides per branch; past a few arms the result is unknown
+ * rather than a product of both operands' branches.
+ */
+export const applyBinaryOperator = (
+ operator: BinaryOperator,
+ left: StaticValue,
+ right: StaticValue,
+ description: string,
+): StaticValue => {
+ if (countArms(left) * countArms(right) > SELECTION_LIMIT) return unknown(description);
+ return mapConditional(left, (leftArm) =>
+ mapConditional(right, (rightArm) => applyToArms(operator, leftArm, rightArm, description)),
+ );
+};
diff --git a/packages/parser/src/analyze/patterns.ts b/packages/parser/src/analyze/patterns.ts
new file mode 100644
index 00000000..e82e118e
--- /dev/null
+++ b/packages/parser/src/analyze/patterns.ts
@@ -0,0 +1,297 @@
+import type {
+ AssignmentTarget,
+ BindingPattern,
+ Expression,
+ ParamPattern,
+ PropertyKey,
+} from "@oxc-project/types";
+import { isStringLiteral, unwrapExpression } from "../module/ast.js";
+import { forgetArrayItems, getIndex, getProperty } from "./access.js";
+import { type EvaluationContext, type Interpreter, isEffectUndecided } from "./interpreter.js";
+import { assignVariable, declareVariable } from "./scope.js";
+import {
+ array,
+ type ArrayValue,
+ assignStatic,
+ forgetStatics,
+ cloneObject,
+ conditional,
+ list,
+ type ObjectValue,
+ type StaticValue,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+export type BindingMode = "declare" | "assign";
+
+const isUndefinedLiteral = (value: StaticValue): boolean =>
+ value.kind === "literal" && value.value === undefined;
+
+/** Resolves a property key to a string, or `null` when it is only known at runtime. */
+export const getPropertyKeyName = (
+ interpreter: Interpreter,
+ key: PropertyKey,
+ isComputed: boolean,
+ context: EvaluationContext,
+): string | null => {
+ if (key.type === "PrivateIdentifier") return `#${key.name}`;
+ if (!isComputed) {
+ if (key.type === "Identifier") return key.name;
+ if (isStringLiteral(key)) return key.value;
+ if (key.type === "Literal" && typeof key.value === "number") return String(key.value);
+ return null;
+ }
+ const evaluated = interpreter.evaluateExpression(key, context);
+ if (evaluated.kind !== "literal") return null;
+ return typeof evaluated.value === "string" || typeof evaluated.value === "number"
+ ? String(evaluated.value)
+ : null;
+};
+
+const applyDefault = (
+ interpreter: Interpreter,
+ value: StaticValue,
+ defaultExpression: Expression,
+ context: EvaluationContext,
+): StaticValue =>
+ isUndefinedLiteral(value) ? interpreter.evaluateExpression(defaultExpression, context) : value;
+
+const restOfObject = (value: StaticValue, consumedKeys: string[]): StaticValue => {
+ if (value.kind !== "object") return unknown(`rest of ${value.kind}`);
+ const rest = cloneObject(value);
+ for (const key of consumedKeys) rest.properties.delete(key);
+ return rest;
+};
+
+const restOfArray = (value: StaticValue, start: number): StaticValue => {
+ if (value.kind === "list") return value;
+ if (value.kind !== "array") return unknown(`rest of ${value.kind}`);
+ const isOffsetKnown = value.items.slice(0, start).every((item) => item.kind !== "optional");
+ return isOffsetKnown ? array(value.items.slice(start)) : list(unknown("rest item"), "array rest");
+};
+
+/**
+ * Binds a destructuring pattern against a value, declaring (or assigning)
+ * every name it introduces. Defaults apply only when the incoming value is
+ * statically `undefined`; unknown values stay unknown.
+ */
+export const bindPattern = (
+ interpreter: Interpreter,
+ pattern: BindingPattern,
+ value: StaticValue,
+ context: EvaluationContext,
+ mode: BindingMode = "declare",
+): void => {
+ switch (pattern.type) {
+ case "Identifier":
+ if (mode === "declare") declareVariable(context.scope, pattern.name, value);
+ else assignVariable(context.scope, pattern.name, value);
+ return;
+ case "AssignmentPattern":
+ bindPattern(
+ interpreter,
+ pattern.left,
+ applyDefault(interpreter, value, pattern.right, context),
+ context,
+ mode,
+ );
+ return;
+ case "ObjectPattern": {
+ const consumedKeys: string[] = [];
+ for (const property of pattern.properties) {
+ if (property.type === "RestElement") {
+ bindPattern(
+ interpreter,
+ property.argument,
+ restOfObject(value, consumedKeys),
+ context,
+ mode,
+ );
+ continue;
+ }
+ const keyName = getPropertyKeyName(interpreter, property.key, property.computed, context);
+ if (keyName !== null) consumedKeys.push(keyName);
+ const propertyValue =
+ keyName === null
+ ? unknown("computed destructuring key")
+ : getProperty(interpreter, value, keyName);
+ bindPattern(interpreter, property.value, propertyValue, context, mode);
+ }
+ return;
+ }
+ case "ArrayPattern":
+ pattern.elements.forEach((element, index) => {
+ if (!element) return;
+ if (element.type === "RestElement") {
+ bindPattern(interpreter, element.argument, restOfArray(value, index), context, mode);
+ return;
+ }
+ bindPattern(
+ interpreter,
+ element,
+ getProperty(interpreter, value, String(index)),
+ context,
+ mode,
+ );
+ });
+ return;
+ }
+};
+
+export const bindParameters = (
+ interpreter: Interpreter,
+ parameters: ParamPattern[],
+ callArguments: StaticValue[],
+ context: EvaluationContext,
+): void => {
+ parameters.forEach((parameter, index) => {
+ if (parameter.type === "RestElement") {
+ bindPattern(interpreter, parameter.argument, array(callArguments.slice(index)), context);
+ return;
+ }
+ const pattern = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter;
+ bindPattern(interpreter, pattern, callArguments[index] ?? UNDEFINED, context);
+ });
+};
+
+/**
+ * Assigns to an assignment target (`x = …`, `obj.prop = …`, `[a, b] = …`).
+ * Member writes mutate object values in place so constructor-style
+ * `this.state = {…}` and `styles.header = …` are observed by later reads.
+ */
+const writeProperty = (
+ target: ObjectValue | ArrayValue,
+ keyName: string,
+ value: StaticValue,
+ context: EvaluationContext,
+): void => {
+ const undecided = context.undecided;
+ const previous = target.properties.get(keyName) ?? UNDEFINED;
+ target.properties.set(
+ keyName,
+ undecided && isEffectUndecided(context, target.depth)
+ ? conditional(undecided.test, value, previous)
+ : value,
+ );
+};
+
+const assignObjectProperty = (
+ target: ObjectValue,
+ keyName: string | null,
+ value: StaticValue,
+ context: EvaluationContext,
+): void => {
+ if (keyName === null) target.hasUnknownSpread = true;
+ else writeProperty(target, keyName, value, context);
+};
+
+/**
+ * `Wrapped.displayName = "…"` inside a factory renames the value every
+ * holder sees, as the runtime assignment does to the function object.
+ */
+/** Writes an item, a named member (`result.ref = …`), or forgets the items when the write cannot be placed. */
+const assignArrayItem = (
+ target: ArrayValue,
+ keyName: string | null,
+ value: StaticValue,
+ description: string,
+ context: EvaluationContext,
+): void => {
+ const index = keyName === null ? null : getIndex(keyName);
+ if (keyName !== null && index === null && keyName !== "length") {
+ writeProperty(target, keyName, value, context);
+ return;
+ }
+ if (index === null || isEffectUndecided(context, target.depth)) {
+ forgetArrayItems(target, description);
+ return;
+ }
+ while (target.items.length < index) target.items.push(UNDEFINED);
+ target.items[index] = value;
+};
+
+export const assignToTarget = (
+ interpreter: Interpreter,
+ target: AssignmentTarget,
+ value: StaticValue,
+ context: EvaluationContext,
+): void => {
+ switch (target.type) {
+ case "Identifier":
+ assignVariable(context.scope, target.name, value);
+ return;
+ case "MemberExpression": {
+ const objectValue = interpreter.evaluateExpression(target.object, context);
+ const keyName = getPropertyKeyName(interpreter, target.property, target.computed, context);
+ if (objectValue.kind === "array") {
+ assignArrayItem(
+ objectValue,
+ keyName,
+ value,
+ interpreter.getSource(context.module, target),
+ context,
+ );
+ } else if (objectValue.kind === "object") {
+ assignObjectProperty(objectValue, keyName, value, context);
+ } else if (keyName === null) {
+ forgetStatics(objectValue);
+ } else {
+ assignStatic(objectValue, keyName, value);
+ }
+ return;
+ }
+ case "ArrayPattern":
+ target.elements.forEach((element, index) => {
+ if (!element) return;
+ const itemValue = getProperty(interpreter, value, String(index));
+ if (element.type === "RestElement") {
+ assignToTarget(interpreter, element.argument, restOfArray(value, index), context);
+ } else if (element.type === "AssignmentPattern") {
+ const withDefault = applyDefault(interpreter, itemValue, element.right, context);
+ assignToTarget(interpreter, element.left, withDefault, context);
+ } else {
+ assignToTarget(interpreter, element, itemValue, context);
+ }
+ });
+ return;
+ case "ObjectPattern": {
+ const consumedKeys: string[] = [];
+ for (const property of target.properties) {
+ if (property.type === "RestElement") {
+ assignToTarget(
+ interpreter,
+ property.argument,
+ restOfObject(value, consumedKeys),
+ context,
+ );
+ continue;
+ }
+ const keyName = getPropertyKeyName(interpreter, property.key, property.computed, context);
+ if (keyName !== null) consumedKeys.push(keyName);
+ const propertyValue =
+ keyName === null
+ ? unknown("computed destructuring key")
+ : getProperty(interpreter, value, keyName);
+ if (property.value.type === "AssignmentPattern") {
+ const withDefault = applyDefault(
+ interpreter,
+ propertyValue,
+ property.value.right,
+ context,
+ );
+ assignToTarget(interpreter, property.value.left, withDefault, context);
+ } else {
+ assignToTarget(interpreter, property.value, propertyValue, context);
+ }
+ }
+ return;
+ }
+ default: {
+ const inner = unwrapExpression(target.expression);
+ if (inner.type === "Identifier" || inner.type === "MemberExpression") {
+ assignToTarget(interpreter, inner, value, context);
+ }
+ }
+ }
+};
diff --git a/packages/parser/src/analyze/react-calls.ts b/packages/parser/src/analyze/react-calls.ts
new file mode 100644
index 00000000..824e42ac
--- /dev/null
+++ b/packages/parser/src/analyze/react-calls.ts
@@ -0,0 +1,213 @@
+import type { Span } from "@oxc-project/types";
+import { getReactApiReference, REACT_BASE_CLASSES } from "../link/react-api.js";
+import { evaluateChildrenApi } from "./children.js";
+import type { EvaluationContext, Interpreter } from "./interpreter.js";
+import { createElementValue } from "./jsx.js";
+import {
+ array,
+ builtin,
+ cloneObject,
+ component,
+ type ExternalValue,
+ FALSE,
+ isNullish,
+ mapConditional,
+ mergeObjects,
+ NULL,
+ object,
+ type ObjectValue,
+ type StaticValue,
+ TRUE,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+export type CallbackInvoker = (callback: StaticValue, callArguments: StaticValue[]) => StaticValue;
+
+const isNullishValue = (value: StaticValue | undefined): boolean =>
+ value === undefined || (value.kind === "literal" && isNullish(value.value));
+
+const childrenProp = (children: StaticValue[]): StaticValue | null => {
+ if (children.length === 0) return null;
+ return children.length === 1 ? children[0] : array(children);
+};
+
+/** `createElement(type, config, ...children)`: config minus `key`/`ref`, children appended. */
+const createElementFromCall = (
+ interpreter: Interpreter,
+ callArguments: StaticValue[],
+ span: Span,
+ context: EvaluationContext,
+): StaticValue => {
+ const [type, config, ...children] = callArguments;
+ if (!type) return unknown("createElement without type");
+ const props: ObjectValue = config?.kind === "object" ? cloneObject(config) : object();
+ if (config && config.kind !== "object" && !isNullishValue(config)) props.hasUnknownSpread = true;
+ const key = props.properties.get("key") ?? null;
+ props.properties.delete("key");
+ const childrenValue = childrenProp(children);
+ if (childrenValue) props.properties.set("children", childrenValue);
+ return createElementValue(interpreter, type, props, key, span, context);
+};
+
+/** `jsx(type, props, key)` from the automatic runtime: children already live in props. */
+const createElementFromJsxRuntime = (
+ interpreter: Interpreter,
+ callArguments: StaticValue[],
+ span: Span,
+ context: EvaluationContext,
+): StaticValue => {
+ const [type, config, key] = callArguments;
+ if (!type) return unknown("jsx without type");
+ const props = config?.kind === "object" ? cloneObject(config) : object([], config !== undefined);
+ return createElementValue(
+ interpreter,
+ type,
+ props,
+ isNullishValue(key) ? null : (key ?? null),
+ span,
+ context,
+ );
+};
+
+/** `cloneElement(element, config, ...children)`, on each element a conditional may hold. */
+const cloneElement = (
+ interpreter: Interpreter,
+ callArguments: StaticValue[],
+ span: Span,
+ context: EvaluationContext,
+): StaticValue => {
+ const [element = UNDEFINED, config, ...children] = callArguments;
+ return mapConditional(element, (arm) => {
+ if (arm.kind !== "element") return unknown("cloneElement of non-element");
+ const props = cloneObject(arm.props);
+ let key = arm.key;
+ if (config?.kind === "object") {
+ mergeObjects(props, config);
+ const configKey = config.properties.get("key");
+ if (configKey) key = configKey;
+ props.properties.delete("key");
+ } else if (config && !isNullishValue(config)) props.hasUnknownSpread = true;
+ const childrenValue = childrenProp(children);
+ if (childrenValue) props.properties.set("children", childrenValue);
+ return {
+ ...createElementValue(interpreter, arm.type, props, key, span, context),
+ owner: arm.owner,
+ };
+ });
+};
+
+const resolveLazyTarget = (interpreter: Interpreter, loaded: StaticValue): StaticValue => {
+ switch (loaded.kind) {
+ case "namespace":
+ return interpreter.getModuleExport(loaded.module, "default");
+ case "object":
+ return loaded.properties.get("default") ?? unknown("lazy module without default export");
+ default:
+ return loaded.kind === "unknown" ? loaded : unknown("lazy loader result");
+ }
+};
+
+/**
+ * `_Component.call(this, props)` in a lowered class: React's base
+ * constructors assign to `this` and return nothing, so `|| this` keeps
+ * the instance.
+ */
+const isBaseConstructorCall = (callee: ExternalValue, path: string[]): boolean =>
+ callee.specifier === "react" &&
+ path.length >= 2 &&
+ REACT_BASE_CLASSES.has(path[path.length - 2]) &&
+ (path[path.length - 1] === "call" || path[path.length - 1] === "apply");
+
+/**
+ * Models calls into React's own API surface. Returns `null` for references
+ * that are not React's, so the caller can fall back to opaque handling.
+ */
+export const evaluateReactCall = (
+ interpreter: Interpreter,
+ callee: ExternalValue,
+ callArguments: StaticValue[],
+ invoke: CallbackInvoker,
+ span: Span,
+ context: EvaluationContext,
+): StaticValue | null => {
+ const path = [callee.importedName, ...callee.memberPath];
+ const description = `${callee.name ?? path.join(".")}()`;
+ const childrenIndex = path.indexOf("Children");
+ if (callee.specifier === "react" && childrenIndex !== -1 && childrenIndex === path.length - 2) {
+ return evaluateChildrenApi(path[childrenIndex + 1], callArguments, invoke, description);
+ }
+ if (isBaseConstructorCall(callee, path)) return UNDEFINED;
+ const reference = getReactApiReference(callee);
+ if (!reference) return null;
+ const [first, second] = callArguments;
+ const spanOf = (): Span => ({ start: span.start, end: span.end });
+ switch (reference.api) {
+ case "memo":
+ return component({
+ kind: "memo",
+ name: null,
+ inner: first ?? unknown("memo without component"),
+ hasCompare: second !== undefined && !isNullishValue(second),
+ span: spanOf(),
+ });
+ case "forwardRef":
+ return component({
+ kind: "forwardRef",
+ name: null,
+ render: first?.kind === "function" ? first : null,
+ span: spanOf(),
+ });
+ case "lazy": {
+ const loaded = first ? invoke(first, []) : unknown("lazy without loader");
+ return component({
+ kind: "lazy",
+ name: null,
+ inner: resolveLazyTarget(interpreter, loaded),
+ span: spanOf(),
+ });
+ }
+ case "createContext":
+ return component({
+ kind: "context",
+ name: null,
+ role: "provider",
+ defaultValue: first ?? UNDEFINED,
+ module: context.module,
+ span: spanOf(),
+ });
+ case "createElement":
+ return createElementFromCall(interpreter, callArguments, span, context);
+ case "jsx":
+ case "jsxs":
+ case "jsxDEV":
+ return createElementFromJsxRuntime(interpreter, callArguments, span, context);
+ case "cloneElement":
+ return cloneElement(interpreter, callArguments, span, context);
+ case "createPortal":
+ return createElementValue(
+ interpreter,
+ builtin("Portal"),
+ object([["children", first ?? NULL]]),
+ callArguments[2] ?? null,
+ span,
+ context,
+ );
+ case "isValidElement":
+ return mapConditional(first ?? UNDEFINED, (arm) => {
+ if (arm.kind === "unknown" || arm.kind === "external") return unknown(description);
+ return arm.kind === "element" ? TRUE : FALSE;
+ });
+ case "createRef":
+ return object([["current", NULL]]);
+ case "startTransition":
+ case "flushSync":
+ case "act":
+ return first ? invoke(first, []) : UNDEFINED;
+ case "cache":
+ case "memoize":
+ return first ?? unknown(description);
+ default:
+ return unknown(description);
+ }
+};
diff --git a/packages/parser/src/analyze/scope.ts b/packages/parser/src/analyze/scope.ts
new file mode 100644
index 00000000..41e97518
--- /dev/null
+++ b/packages/parser/src/analyze/scope.ts
@@ -0,0 +1,121 @@
+import { conditional, type StaticValue } from "./values.js";
+
+/**
+ * - `lexical`: a module, function or block body; declarations live here.
+ * - `branch`: a control-flow arm; assignments shadow the outer binding until
+ * the arms are merged back as a conditional.
+ * - `narrowing`: a path on which a test's outcome is known; holds refined
+ * copies of outer variables and never receives writes.
+ */
+export type ScopeKind = "lexical" | "branch" | "narrowing";
+
+export interface Scope {
+ parent: Scope | null;
+ variables: Map;
+ kind: ScopeKind;
+ /** Names assigned (not declared) inside a branch scope. */
+ assigned: Set;
+}
+
+export interface BranchOutcome {
+ test: string;
+ scope: Scope;
+}
+
+export const createScope = (parent: Scope | null = null, kind: ScopeKind = "lexical"): Scope => ({
+ parent,
+ variables: new Map(),
+ kind,
+ assigned: new Set(),
+});
+
+export const isModuleScope = (scope: Scope): boolean => scope.parent === null;
+
+/** Whether `ancestor` is `scope` itself or one of the scopes it is nested in. */
+export const isEnclosingScope = (ancestor: Scope, scope: Scope): boolean => {
+ let current: Scope | null = scope;
+ while (current) {
+ if (current === ancestor) return true;
+ current = current.parent;
+ }
+ return false;
+};
+
+export const lookupVariable = (scope: Scope, name: string): StaticValue | undefined => {
+ let current: Scope | null = scope;
+ while (current) {
+ const value = current.variables.get(name);
+ if (value !== undefined) return value;
+ current = current.parent;
+ }
+ return undefined;
+};
+
+/** Whether `name` is bound in a function-local scope (not the module scope). */
+export const hasLocalBinding = (scope: Scope, name: string): boolean => {
+ let current: Scope | null = scope;
+ while (current && !isModuleScope(current)) {
+ if (current.variables.has(name)) return true;
+ current = current.parent;
+ }
+ return false;
+};
+
+export const declareVariable = (scope: Scope, name: string, value: StaticValue): void => {
+ scope.variables.set(name, value);
+};
+
+/**
+ * Assigns to the nearest declaring scope, unless a branch scope is crossed
+ * first: then the assignment shadows inside the branch so the other arms
+ * still observe the previous value. A narrowing scope crossed on the way
+ * drops its refinement, since the write supersedes what the path assumed.
+ */
+export const assignVariable = (scope: Scope, name: string, value: StaticValue): void => {
+ let current: Scope | null = scope;
+ while (current) {
+ if (current.kind === "narrowing") {
+ current.variables.delete(name);
+ } else if (current.variables.has(name)) {
+ current.variables.set(name, value);
+ return;
+ } else if (current.kind === "branch") {
+ current.variables.set(name, value);
+ current.assigned.add(name);
+ return;
+ }
+ current = current.parent;
+ }
+ scope.variables.set(name, value);
+};
+
+export const forkScope = (scope: Scope): Scope => createScope(scope, "branch");
+
+/**
+ * Merges branch scopes back into `scope`: every variable assigned in any arm
+ * becomes a chain of conditionals over the arms' outcomes, falling back to
+ * the value before the branch when an arm left it untouched.
+ */
+export const mergeBranchScopes = (
+ scope: Scope,
+ arms: BranchOutcome[],
+ fallback: Scope | null,
+): void => {
+ const assignedNames = new Set();
+ for (const arm of arms) for (const name of arm.scope.assigned) assignedNames.add(name);
+ if (fallback) for (const name of fallback.assigned) assignedNames.add(name);
+ for (const name of assignedNames) {
+ const before = lookupVariable(scope, name);
+ const valueIn = (branch: Scope | null): StaticValue | undefined =>
+ branch?.assigned.has(name) ? branch.variables.get(name) : before;
+ const fallbackValue = valueIn(fallback);
+ if (fallbackValue === undefined) continue;
+ let merged: StaticValue = fallbackValue;
+ for (let index = arms.length - 1; index >= 0; index--) {
+ const armValue = valueIn(arms[index].scope);
+ if (armValue === undefined) continue;
+ merged = armValue === merged ? merged : conditional(arms[index].test, armValue, merged);
+ }
+ assignVariable(scope, name, merged);
+ }
+};
diff --git a/packages/parser/src/analyze/statements.ts b/packages/parser/src/analyze/statements.ts
new file mode 100644
index 00000000..96b1b8ee
--- /dev/null
+++ b/packages/parser/src/analyze/statements.ts
@@ -0,0 +1,753 @@
+import type {
+ Expression,
+ ForInStatement,
+ ForOfStatement,
+ ForStatement,
+ ForStatementLeft,
+ IfStatement,
+ Span,
+ Statement,
+ SwitchStatement,
+ TryStatement,
+ VariableDeclarator,
+} from "@oxc-project/types";
+import {
+ collectAssignedNames,
+ collectIdentifierNames,
+ isAnonymousFunctionDefinition,
+ isNodeOfType,
+ walk,
+} from "../module/ast.js";
+import { getIterationItem } from "./access.js";
+import { classifyClass } from "./components.js";
+import { evaluateEnum } from "./enums.js";
+import {
+ BREAK_COMPLETION,
+ type Completion,
+ CONTINUE_COMPLETION,
+ enterUndecided,
+ type EvaluationContext,
+ type Interpreter,
+ type JumpKind,
+ NORMAL_COMPLETION,
+ returnCompletion,
+ THROW_COMPLETION,
+} from "./interpreter.js";
+import {
+ collectCaseNarrowings,
+ collectNarrowings,
+ type Narrowing,
+ narrowScope,
+} from "./narrowing.js";
+import { assignToTarget, bindPattern } from "./patterns.js";
+import {
+ assignVariable,
+ createScope,
+ declareVariable,
+ forkScope,
+ lookupVariable,
+ mergeBranchScopes,
+ type Scope,
+} from "./scope.js";
+import {
+ conditional,
+ getTruthiness,
+ literal,
+ nameValue,
+ type StaticValue,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+interface Arm {
+ test: string;
+ scope: Scope;
+ completion: Completion;
+ /** What holds for the statements after the branch when this arm leaves the function. */
+ exitNarrowings: Narrowing[];
+}
+
+/** Outcome of a control-flow statement whose direction is unknown statically. */
+interface ArmSet {
+ kind: "arms";
+ arms: Arm[];
+ /** The `else` / `default` / `catch` arm; `null` when the statement may be skipped entirely. */
+ fallback: Arm | null;
+}
+
+const toStatements = (statement: Statement): Statement[] =>
+ statement.type === "BlockStatement" ? statement.body : [statement];
+
+const evaluateInScope = (
+ interpreter: Interpreter,
+ statements: Statement[],
+ scope: Scope,
+ context: EvaluationContext,
+): Completion => interpreter.evaluateStatements(statements, { ...context, scope });
+
+/** Variables refined by a test's outcome inside the arm, and by the opposite outcome after it. */
+interface ArmNarrowings {
+ inside: Narrowing[];
+ exit: Narrowing[];
+}
+
+const NO_NARROWINGS: ArmNarrowings = { inside: [], exit: [] };
+
+const testNarrowings = (
+ interpreter: Interpreter,
+ test: Expression,
+ outcome: boolean,
+ context: EvaluationContext,
+): ArmNarrowings => {
+ const describe = (expression: Expression): string =>
+ interpreter.getSource(context.module, expression);
+ return {
+ inside: collectNarrowings(test, outcome, describe),
+ exit: collectNarrowings(test, !outcome, describe),
+ };
+};
+
+const evaluateArm = (
+ interpreter: Interpreter,
+ test: string,
+ statements: Statement[],
+ context: EvaluationContext,
+ narrowings: ArmNarrowings = NO_NARROWINGS,
+): Arm => {
+ const scope = forkScope(narrowScope(context.scope, narrowings.inside));
+ return {
+ test,
+ scope,
+ completion: interpreter.evaluateStatements(statements, enterUndecided(context, test, scope)),
+ exitNarrowings: narrowings.exit,
+ };
+};
+
+const LOOP_JUMPS: JumpKind[] = ["break", "continue"];
+const SWITCH_JUMPS: JumpKind[] = ["break"];
+
+/** Jumps end a switch case or loop iteration, not the enclosing function. */
+const absorbJumps = (arm: Arm, jumps: JumpKind[]): Arm =>
+ jumps.some((jump) => jump === arm.completion.kind)
+ ? { ...arm, completion: NORMAL_COMPLETION }
+ : arm;
+
+const hoistFunctionDeclarations = (statements: Statement[], context: EvaluationContext): void => {
+ for (const statement of statements) {
+ if (statement.type !== "FunctionDeclaration" || !statement.id) continue;
+ declareVariable(context.scope, statement.id.name, {
+ kind: "function",
+ fn: statement,
+ module: context.module,
+ scope: context.scope,
+ thisValue: null,
+ name: statement.id.name,
+ statics: new Map(),
+ hasUnknownStatics: false,
+ });
+ }
+};
+
+const evaluateIf = (
+ interpreter: Interpreter,
+ statement: IfStatement,
+ context: EvaluationContext,
+): Completion | ArmSet => {
+ const truthiness = getTruthiness(interpreter.evaluateExpression(statement.test, context));
+ if (truthiness === true) {
+ return evaluateInScope(
+ interpreter,
+ toStatements(statement.consequent),
+ createScope(context.scope),
+ context,
+ );
+ }
+ if (truthiness === false) {
+ return statement.alternate
+ ? evaluateInScope(
+ interpreter,
+ toStatements(statement.alternate),
+ createScope(context.scope),
+ context,
+ )
+ : NORMAL_COMPLETION;
+ }
+ const test = interpreter.getSource(context.module, statement.test);
+ return {
+ kind: "arms",
+ arms: [
+ evaluateArm(
+ interpreter,
+ test,
+ toStatements(statement.consequent),
+ context,
+ testNarrowings(interpreter, statement.test, true, context),
+ ),
+ ],
+ fallback: statement.alternate
+ ? evaluateArm(
+ interpreter,
+ `!(${test})`,
+ toStatements(statement.alternate),
+ context,
+ testNarrowings(interpreter, statement.test, false, context),
+ )
+ : null,
+ };
+};
+
+interface CaseGroup {
+ tests: Expression[];
+ isDefault: boolean;
+ statements: Statement[];
+}
+
+/** Cases with empty bodies fall through into the next non-empty case. */
+const groupSwitchCases = (statement: SwitchStatement): CaseGroup[] => {
+ const groups: CaseGroup[] = [];
+ let pending: CaseGroup = { tests: [], isDefault: false, statements: [] };
+ for (const switchCase of statement.cases) {
+ if (switchCase.test) pending.tests.push(switchCase.test);
+ else pending.isDefault = true;
+ if (switchCase.consequent.length === 0) continue;
+ pending.statements = switchCase.consequent;
+ groups.push(pending);
+ pending = { tests: [], isDefault: false, statements: [] };
+ }
+ if (pending.tests.length > 0 || pending.isDefault) groups.push(pending);
+ return groups;
+};
+
+const evaluateSwitch = (
+ interpreter: Interpreter,
+ statement: SwitchStatement,
+ context: EvaluationContext,
+): Completion | ArmSet => {
+ const discriminant = interpreter.evaluateExpression(statement.discriminant, context);
+ const testValues = statement.cases.map((switchCase) =>
+ switchCase.test ? interpreter.evaluateExpression(switchCase.test, context) : null,
+ );
+ const isDecidable =
+ discriminant.kind === "literal" &&
+ testValues.every((testValue) => testValue === null || testValue.kind === "literal");
+ if (isDecidable) {
+ const matchIndex = testValues.findIndex(
+ (testValue) => testValue?.kind === "literal" && testValue.value === discriminant.value,
+ );
+ const startIndex = matchIndex === -1 ? testValues.indexOf(null) : matchIndex;
+ if (startIndex === -1) return NORMAL_COMPLETION;
+ const statements = statement.cases
+ .slice(startIndex)
+ .flatMap((switchCase) => switchCase.consequent);
+ const completion = evaluateInScope(
+ interpreter,
+ statements,
+ createScope(context.scope),
+ context,
+ );
+ return completion.kind === "break" ? NORMAL_COMPLETION : completion;
+ }
+ const discriminantSource = interpreter.getSource(context.module, statement.discriminant);
+ const groups = groupSwitchCases(statement);
+ const allTests = groups.flatMap((group) => group.tests);
+ const arms: Arm[] = [];
+ let fallback: Arm | null = null;
+ for (const group of groups) {
+ const test = group.tests
+ .map(
+ (caseTest) =>
+ `${discriminantSource} === ${interpreter.getSource(context.module, caseTest)}`,
+ )
+ .join(" || ");
+ /** The default case is reached once no other case matched. */
+ const narrowings: ArmNarrowings = group.isDefault
+ ? {
+ inside:
+ group.tests.length === 0
+ ? collectCaseNarrowings(statement.discriminant, allTests, false)
+ : [],
+ exit: [],
+ }
+ : {
+ inside: collectCaseNarrowings(statement.discriminant, group.tests, true),
+ exit: collectCaseNarrowings(statement.discriminant, group.tests, false),
+ };
+ const arm = absorbJumps(
+ evaluateArm(interpreter, test || "default", group.statements, context, narrowings),
+ SWITCH_JUMPS,
+ );
+ if (group.isDefault && group.tests.length === 0) fallback = arm;
+ else arms.push(arm);
+ }
+ return { kind: "arms", arms, fallback };
+};
+
+const bindLoopVariable = (
+ interpreter: Interpreter,
+ left: ForStatementLeft,
+ value: StaticValue,
+ context: EvaluationContext,
+): void => {
+ if (left.type === "VariableDeclaration") {
+ for (const declarator of left.declarations)
+ bindPattern(interpreter, declarator.id, value, context);
+ return;
+ }
+ assignToTarget(interpreter, left, value, context);
+};
+
+/** `for (let index = 0; …)` counters differ per iteration, so the body sees them as unknown. */
+const forgetLoopCounters = (declarations: VariableDeclarator[], scope: Scope): void => {
+ for (const declarator of declarations) {
+ if (declarator.id.type !== "Identifier") continue;
+ declareVariable(scope, declarator.id.name, unknown(`loop counter ${declarator.id.name}`));
+ }
+};
+
+const MAX_UNROLLED_ITERATIONS = 100;
+
+/** Binds one iteration's variables into the scope the body will run in. */
+type IterationBinding = (context: EvaluationContext) => void;
+
+interface LoopBodyFacts {
+ /** Identifiers assigned or updated anywhere in the body, nested closures included. */
+ assignedNames: Set;
+ /** A `break` that leaves this loop rather than a nested loop or switch. */
+ hasBreak: boolean;
+}
+
+const BREAK_TARGETS = new Set([
+ "ForStatement",
+ "ForInStatement",
+ "ForOfStatement",
+ "WhileStatement",
+ "DoWhileStatement",
+ "SwitchStatement",
+]);
+
+const contains = (outer: Span, inner: Span): boolean =>
+ outer.start <= inner.start && inner.end <= outer.end;
+
+/**
+ * A conditional `break` would make later iterations conditional, which the
+ * unrolled model cannot express; `continue` only ends the current iteration.
+ */
+const collectLoopBodyFacts = (body: Statement): LoopBodyFacts => {
+ const breaks: { label: object | null; span: Span }[] = [];
+ const nestedTargets: Span[] = [];
+ walk(body, (node) => {
+ if (isNodeOfType(node, "BreakStatement")) breaks.push({ label: node.label, span: node });
+ else if (BREAK_TARGETS.has(node.type)) nestedTargets.push(node);
+ });
+ const hasBreak = breaks.some(
+ (jump) => jump.label !== null || !nestedTargets.some((target) => contains(target, jump.span)),
+ );
+ return { assignedNames: collectAssignedNames(body), hasBreak };
+};
+
+/** The identifiers a `for` header initialises, whether it declares them or assigns outer ones. */
+const getForCounters = (init: NonNullable): string[] | null => {
+ if (init.type === "VariableDeclaration") {
+ const names = init.declarations.flatMap((declarator) =>
+ declarator.id.type === "Identifier" ? [declarator.id.name] : [],
+ );
+ return names.length === init.declarations.length ? names : null;
+ }
+ const assignments = init.type === "SequenceExpression" ? init.expressions : [init];
+ const names = assignments.flatMap((expression) =>
+ expression.type === "AssignmentExpression" &&
+ expression.operator === "=" &&
+ expression.left.type === "Identifier"
+ ? [expression.left.name]
+ : [],
+ );
+ return names.length === assignments.length ? names : null;
+};
+
+/**
+ * Simulates a `for` header without the body: iterations are known when the
+ * test stays decidable, the body never breaks and never assigns anything the
+ * header reads. Counters the header assigns rather than declares keep their
+ * final value once the loop is done.
+ */
+const planForIterations = (
+ interpreter: Interpreter,
+ statement: ForStatement,
+ context: EvaluationContext,
+): IterationBinding[] | null => {
+ const { init, test, update } = statement;
+ if (!init || !test) return null;
+ const counters = getForCounters(init);
+ if (counters === null) return null;
+ const facts = collectLoopBodyFacts(statement.body);
+ if (facts.hasBreak) return null;
+ const headerNames = new Set();
+ collectIdentifierNames(test, headerNames);
+ if (update) collectIdentifierNames(update, headerNames);
+ if ([...headerNames].some((name) => facts.assignedNames.has(name))) return null;
+ const scratchContext: EvaluationContext = { ...context, scope: forkScope(context.scope) };
+ if (init.type === "VariableDeclaration") evaluateStatement(interpreter, init, scratchContext);
+ else interpreter.evaluateExpression(init, scratchContext);
+ const iterations: IterationBinding[] = [];
+ while (iterations.length <= MAX_UNROLLED_ITERATIONS) {
+ const truthiness = getTruthiness(interpreter.evaluateExpression(test, scratchContext));
+ if (truthiness === null) return null;
+ if (truthiness === false) {
+ if (init.type !== "VariableDeclaration") {
+ for (const name of counters) {
+ assignVariable(
+ context.scope,
+ name,
+ lookupVariable(scratchContext.scope, name) ?? UNDEFINED,
+ );
+ }
+ }
+ return iterations;
+ }
+ const values = counters.map((name): [string, StaticValue] => [
+ name,
+ lookupVariable(scratchContext.scope, name) ?? UNDEFINED,
+ ]);
+ iterations.push((iterationContext) => {
+ for (const [name, value] of values) declareVariable(iterationContext.scope, name, value);
+ });
+ if (update) interpreter.evaluateExpression(update, scratchContext);
+ }
+ return null;
+};
+
+const planForOfIterations = (
+ interpreter: Interpreter,
+ statement: ForOfStatement | ForInStatement,
+ context: EvaluationContext,
+): IterationBinding[] | null => {
+ if (collectLoopBodyFacts(statement.body).hasBreak) return null;
+ const subject = interpreter.evaluateExpression(statement.right, context);
+ const values =
+ statement.type === "ForOfStatement"
+ ? subject.kind === "array" && subject.items.every((item) => item.kind !== "optional")
+ ? subject.items
+ : null
+ : subject.kind === "object" && !subject.hasUnknownSpread
+ ? [...subject.properties.keys()].map((key) => literal(key))
+ : null;
+ if (values === null || values.length > MAX_UNROLLED_ITERATIONS) return null;
+ return values.map(
+ (value): IterationBinding =>
+ (iterationContext) =>
+ bindLoopVariable(interpreter, statement.left, value, iterationContext),
+ );
+};
+
+/** Runs every planned iteration in order, chaining early returns like a statement sequence. */
+const runIterations = (
+ interpreter: Interpreter,
+ iterations: IterationBinding[],
+ body: Statement,
+ context: EvaluationContext,
+ index = 0,
+): Completion => {
+ if (index >= iterations.length) return NORMAL_COMPLETION;
+ const iterationContext: EvaluationContext = { ...context, scope: createScope(context.scope) };
+ iterations[index](iterationContext);
+ const completion = interpreter.evaluateStatements(toStatements(body), iterationContext);
+ if (completion.kind === "return" || completion.kind === "throw") return completion;
+ if (completion.kind === "break") return NORMAL_COMPLETION;
+ const rest = (): Completion => runIterations(interpreter, iterations, body, context, index + 1);
+ return completion.kind === "partial" ? continuePartial(completion.complete, rest()) : rest();
+};
+
+/**
+ * Loops with a statically known trip count run iteration by iteration. Any
+ * other loop body runs once under an undecided frame: zero iterations is
+ * always possible, and repeated side effects become lists.
+ */
+const evaluateLoop = (
+ interpreter: Interpreter,
+ statement: Statement,
+ context: EvaluationContext,
+): Completion | ArmSet | null => {
+ let body: Statement;
+ let iterations: IterationBinding[] | null = null;
+ switch (statement.type) {
+ case "ForStatement":
+ iterations = planForIterations(interpreter, statement, context);
+ body = statement.body;
+ break;
+ case "ForOfStatement":
+ case "ForInStatement":
+ iterations = planForOfIterations(interpreter, statement, context);
+ body = statement.body;
+ break;
+ case "WhileStatement":
+ case "DoWhileStatement":
+ body = statement.body;
+ break;
+ default:
+ return null;
+ }
+ if (iterations) return runIterations(interpreter, iterations, body, context);
+ const header = interpreter.getSource(context.module, { start: statement.start, end: body.start });
+ const scope = forkScope(context.scope);
+ const loopContext = enterUndecided(context, header, scope);
+ switch (statement.type) {
+ case "ForStatement":
+ if (statement.init?.type === "VariableDeclaration") {
+ evaluateStatement(interpreter, statement.init, loopContext);
+ forgetLoopCounters(statement.init.declarations, scope);
+ } else if (statement.init) interpreter.evaluateExpression(statement.init, loopContext);
+ break;
+ case "ForOfStatement": {
+ const iterable = interpreter.evaluateExpression(statement.right, loopContext);
+ const description = interpreter.getSource(context.module, statement.right);
+ bindLoopVariable(
+ interpreter,
+ statement.left,
+ getIterationItem(iterable, description),
+ loopContext,
+ );
+ break;
+ }
+ case "ForInStatement":
+ bindLoopVariable(interpreter, statement.left, unknown("enumerated key"), loopContext);
+ break;
+ }
+ const completion = evaluateInScope(interpreter, toStatements(body), scope, loopContext);
+ return {
+ kind: "arms",
+ arms: [absorbJumps({ test: header, scope, completion, exitNarrowings: [] }, LOOP_JUMPS)],
+ fallback: null,
+ };
+};
+
+/**
+ * A `catch` handler is one more arm, since any call in the block may throw
+ * at runtime; it is the only arm once the block itself is known to throw.
+ */
+const evaluateTry = (
+ interpreter: Interpreter,
+ statement: TryStatement,
+ context: EvaluationContext,
+): Completion | ArmSet => {
+ const tryArm = evaluateArm(interpreter, "try", statement.block.body, context);
+ const isThrowing = tryArm.completion.kind === "throw";
+ let fallback: Arm | null = null;
+ if (statement.handler) {
+ const scope = forkScope(context.scope);
+ const handlerContext = enterUndecided(context, "catch", scope);
+ if (statement.handler.param) {
+ bindPattern(interpreter, statement.handler.param, unknown("caught error"), handlerContext);
+ }
+ fallback = {
+ test: "catch",
+ scope,
+ completion: interpreter.evaluateStatements(statement.handler.body.body, handlerContext),
+ exitNarrowings: [],
+ };
+ }
+ if (statement.finalizer) interpreter.evaluateStatements(statement.finalizer.body, context);
+ if (isThrowing && fallback === null) return THROW_COMPLETION;
+ return { kind: "arms", arms: isThrowing ? [] : [tryArm], fallback };
+};
+
+const evaluateStatement = (
+ interpreter: Interpreter,
+ statement: Statement,
+ context: EvaluationContext,
+): Completion | ArmSet => {
+ switch (statement.type) {
+ case "IfStatement":
+ return evaluateIf(interpreter, statement, context);
+ case "SwitchStatement":
+ return evaluateSwitch(interpreter, statement, context);
+ case "TryStatement":
+ return evaluateTry(interpreter, statement, context);
+ case "ForStatement":
+ case "ForInStatement":
+ case "ForOfStatement":
+ case "WhileStatement":
+ case "DoWhileStatement":
+ return evaluateLoop(interpreter, statement, context) ?? NORMAL_COMPLETION;
+ case "LabeledStatement":
+ return evaluateStatement(interpreter, statement.body, context);
+ case "BlockStatement":
+ return evaluateInScope(interpreter, statement.body, createScope(context.scope), context);
+ case "VariableDeclaration":
+ for (const declarator of statement.declarations) {
+ const nameHint = declarator.id.type === "Identifier" ? declarator.id.name : null;
+ const value = declarator.init
+ ? nameValue(
+ interpreter.evaluateExpression(declarator.init, context),
+ nameHint,
+ isAnonymousFunctionDefinition(declarator.init),
+ )
+ : UNDEFINED;
+ bindPattern(interpreter, declarator.id, value, context);
+ }
+ return NORMAL_COMPLETION;
+ case "ClassDeclaration":
+ if (statement.id) {
+ const classValue = classifyClass(
+ interpreter,
+ statement,
+ context.module,
+ context.scope,
+ statement.id.name,
+ context,
+ );
+ declareVariable(context.scope, statement.id.name, classValue);
+ }
+ return NORMAL_COMPLETION;
+ case "TSEnumDeclaration":
+ declareVariable(
+ context.scope,
+ statement.id.name,
+ evaluateEnum(interpreter, statement, context),
+ );
+ return NORMAL_COMPLETION;
+ case "ReturnStatement":
+ return returnCompletion(
+ statement.argument
+ ? interpreter.evaluateExpression(statement.argument, context)
+ : UNDEFINED,
+ );
+ case "ExpressionStatement":
+ interpreter.evaluateExpression(statement.expression, context);
+ return NORMAL_COMPLETION;
+ case "ThrowStatement":
+ return THROW_COMPLETION;
+ case "BreakStatement":
+ return BREAK_COMPLETION;
+ case "ContinueStatement":
+ return CONTINUE_COMPLETION;
+ default:
+ return NORMAL_COMPLETION;
+ }
+};
+
+const isReturning = (completion: Completion): boolean =>
+ completion.kind === "return" || completion.kind === "partial";
+
+const valueOf = (arm: Arm, restValue: StaticValue): StaticValue => {
+ switch (arm.completion.kind) {
+ case "return":
+ return arm.completion.value;
+ case "partial":
+ return arm.completion.complete(restValue);
+ default:
+ return restValue;
+ }
+};
+
+const isLive = (arm: Arm): boolean => arm.completion.kind !== "throw";
+
+/**
+ * Joins the arms of a branch. Arms that throw leave the render path and are
+ * dropped. Every live arm returning gives a conditional over their values;
+ * otherwise the result stays partial and is completed with whatever the
+ * statements after the branch evaluate to.
+ */
+const combineArms = (armSet: ArmSet, context: EvaluationContext): Completion => {
+ const arms = armSet.arms.filter(isLive);
+ const fallback = armSet.fallback && isLive(armSet.fallback) ? armSet.fallback : null;
+ /** Control reaches the next statement without entering any arm. */
+ const isSkippable = armSet.fallback === null;
+ const liveArms = fallback ? [...arms, fallback] : arms;
+ if (liveArms.length === 0) return isSkippable ? NORMAL_COMPLETION : THROW_COMPLETION;
+ const returningArms = liveArms.filter((arm) => isReturning(arm.completion));
+ const continuingArms = arms.filter((arm) => !isReturning(arm.completion));
+ const continuingFallback = fallback && !isReturning(fallback.completion) ? fallback.scope : null;
+ mergeBranchScopes(context.scope, continuingArms, continuingFallback);
+ if (returningArms.length === 0) {
+ const [first] = liveArms;
+ const isSharedJump =
+ !isSkippable &&
+ LOOP_JUMPS.some((jump) => jump === first.completion.kind) &&
+ liveArms.every((arm) => arm.completion.kind === first.completion.kind);
+ return isSharedJump ? first.completion : NORMAL_COMPLETION;
+ }
+ /** The last live arm needs no test once every other path is dead or tested. */
+ const testedArms = isSkippable ? liveArms : liveArms.slice(0, -1);
+ const complete = (restValue: StaticValue): StaticValue => {
+ let merged = isSkippable ? restValue : valueOf(liveArms[liveArms.length - 1], restValue);
+ for (let index = testedArms.length - 1; index >= 0; index--) {
+ const arm = testedArms[index];
+ merged = conditional(arm.test, valueOf(arm, restValue), merged);
+ }
+ return merged;
+ };
+ const everyPathReturns =
+ !isSkippable && liveArms.every((arm) => arm.completion.kind === "return");
+ return everyPathReturns ? returnCompletion(complete(UNDEFINED)) : { kind: "partial", complete };
+};
+
+/** Chains a partial completion with what the remaining statements produced. */
+const continuePartial = (
+ partial: (restValue: StaticValue) => StaticValue,
+ rest: Completion,
+): Completion => {
+ switch (rest.kind) {
+ case "return":
+ return returnCompletion(partial(rest.value));
+ case "partial":
+ return { kind: "partial", complete: (restValue) => partial(rest.complete(restValue)) };
+ default:
+ return { kind: "partial", complete: partial };
+ }
+};
+
+const leavesFunction = (arm: Arm): boolean =>
+ arm.completion.kind === "return" || arm.completion.kind === "throw";
+
+/**
+ * The statements after a branch only run on paths through arms that did not
+ * leave. Variables those paths narrow are refined; when some arm returned,
+ * the rest is also undecided control flow, so what it does to values shared
+ * with the returning paths stays conditional.
+ */
+const narrowAfterBranch = (armSet: ArmSet, context: EvaluationContext): EvaluationContext => {
+ const allArms = [...armSet.arms, ...(armSet.fallback ? [armSet.fallback] : [])];
+ const scope = narrowScope(
+ context.scope,
+ allArms.filter(leavesFunction).flatMap((arm) => arm.exitNarrowings),
+ );
+ const returningTests = allArms
+ .filter((arm) => isReturning(arm.completion))
+ .map((arm) => `!(${arm.test})`);
+ if (returningTests.length > 0) return enterUndecided(context, returningTests.join(" && "), scope);
+ return scope === context.scope ? context : { ...context, scope };
+};
+
+const evaluateSequence = (
+ interpreter: Interpreter,
+ statements: Statement[],
+ startIndex: number,
+ initialContext: EvaluationContext,
+): Completion => {
+ let context = initialContext;
+ for (let index = startIndex; index < statements.length; index++) {
+ const outcome = evaluateStatement(interpreter, statements[index], context);
+ if (outcome.kind !== "arms") {
+ if (outcome.kind === "normal") continue;
+ return outcome;
+ }
+ const completion = combineArms(outcome, context);
+ context = narrowAfterBranch(outcome, context);
+ if (completion.kind === "normal") continue;
+ if (completion.kind !== "partial") return completion;
+ return continuePartial(
+ completion.complete,
+ evaluateSequence(interpreter, statements, index + 1, context),
+ );
+ }
+ return NORMAL_COMPLETION;
+};
+
+export const evaluateStatements = (
+ interpreter: Interpreter,
+ statements: Statement[],
+ context: EvaluationContext,
+): Completion => {
+ hoistFunctionDeclarations(statements, context);
+ return evaluateSequence(interpreter, statements, 0, context);
+};
diff --git a/packages/parser/src/analyze/strings.ts b/packages/parser/src/analyze/strings.ts
new file mode 100644
index 00000000..f05dfebd
--- /dev/null
+++ b/packages/parser/src/analyze/strings.ts
@@ -0,0 +1,179 @@
+import type { CallbackInvoker } from "./react-calls.js";
+import {
+ array,
+ list,
+ literal,
+ NULL,
+ type Primitive,
+ type RegExpValue,
+ type StaticValue,
+ text,
+ toRegExp,
+ unknown,
+} from "./values.js";
+
+/** Pure `String.prototype` methods that are run for real when every operand is known. */
+const STRING_METHODS = new Set([
+ "at",
+ "charAt",
+ "charCodeAt",
+ "codePointAt",
+ "concat",
+ "endsWith",
+ "includes",
+ "indexOf",
+ "lastIndexOf",
+ "localeCompare",
+ "match",
+ "normalize",
+ "padEnd",
+ "padStart",
+ "repeat",
+ "replace",
+ "replaceAll",
+ "search",
+ "slice",
+ "split",
+ "startsWith",
+ "substring",
+ "substr",
+ "toLocaleLowerCase",
+ "toLocaleUpperCase",
+ "toLowerCase",
+ "toString",
+ "toUpperCase",
+ "trim",
+ "trimEnd",
+ "trimStart",
+ "valueOf",
+]);
+
+const STRING_METHODS_RETURNING_STRING = new Set([
+ "at",
+ "charAt",
+ "concat",
+ "normalize",
+ "padEnd",
+ "padStart",
+ "repeat",
+ "replace",
+ "replaceAll",
+ "slice",
+ "substring",
+ "substr",
+ "toLocaleLowerCase",
+ "toLocaleUpperCase",
+ "toLowerCase",
+ "toString",
+ "toUpperCase",
+ "trim",
+ "trimEnd",
+ "trimStart",
+ "valueOf",
+]);
+
+const REGEXP_METHODS = new Set(["test", "exec", "toString"]);
+
+type ConcreteArgument = Primitive | RegExp | ((...callbackArguments: unknown[]) => Primitive);
+
+const UNCONVERTIBLE = Symbol("unconvertible");
+
+interface Concretization {
+ invoke: CallbackInvoker;
+ isPrecise: boolean;
+}
+
+const isStringValue = (value: StaticValue): value is StaticValue & { kind: "literal" | "text" } =>
+ value.kind === "text" || (value.kind === "literal" && typeof value.value === "string");
+
+const fromConcrete = (value: unknown): StaticValue => {
+ if (Array.isArray(value)) return array(value.map(fromConcrete));
+ switch (typeof value) {
+ case "string":
+ case "number":
+ case "boolean":
+ case "bigint":
+ case "undefined":
+ return literal(value);
+ default:
+ return value === null ? NULL : unknown("string method result");
+ }
+};
+
+/**
+ * Lowers a static value to the JavaScript value a string method needs.
+ * Closures become real callbacks that run through the interpreter; a
+ * callback result that is not a literal taints the whole computation.
+ */
+const toConcrete = (
+ value: StaticValue,
+ concretization: Concretization,
+): ConcreteArgument | typeof UNCONVERTIBLE => {
+ switch (value.kind) {
+ case "literal":
+ return value.value;
+ case "regexp":
+ return toRegExp(value);
+ case "function":
+ return (...callbackArguments) => {
+ const result = concretization.invoke(value, callbackArguments.map(fromConcrete));
+ if (result.kind === "literal") return result.value;
+ concretization.isPrecise = false;
+ return "";
+ };
+ default:
+ return UNCONVERTIBLE;
+ }
+};
+
+const runConcretely = (
+ receiver: string | RegExp,
+ method: string,
+ callArguments: StaticValue[],
+ invoke: CallbackInvoker,
+): StaticValue | null => {
+ const concretization: Concretization = { invoke, isPrecise: true };
+ const concreteArguments: ConcreteArgument[] = [];
+ for (const argument of callArguments) {
+ const concrete = toConcrete(argument, concretization);
+ if (concrete === UNCONVERTIBLE) return null;
+ concreteArguments.push(concrete);
+ }
+ const implementation = Reflect.get(Object(receiver), method);
+ if (typeof implementation !== "function") return null;
+ try {
+ const result = Reflect.apply(implementation, receiver, concreteArguments);
+ return concretization.isPrecise ? fromConcrete(result) : null;
+ } catch {
+ return null;
+ }
+};
+
+/** Method calls on string receivers; `null` when the method is not a string method. */
+export const evaluateStringMethod = (
+ target: StaticValue,
+ method: string,
+ callArguments: StaticValue[],
+ invoke: CallbackInvoker,
+ description: string,
+): StaticValue | null => {
+ if (!isStringValue(target) || !STRING_METHODS.has(method)) return null;
+ if (target.kind === "literal" && typeof target.value === "string") {
+ const concrete = runConcretely(target.value, method, callArguments, invoke);
+ if (concrete) return concrete;
+ }
+ if (method === "split") return list(text(`part of ${description}`), description);
+ return STRING_METHODS_RETURNING_STRING.has(method) ? text(description) : unknown(description);
+};
+
+/** `test`, `exec` and `toString` on regular expression literals. */
+export const evaluateRegExpMethod = (
+ target: RegExpValue,
+ method: string,
+ callArguments: StaticValue[],
+ invoke: CallbackInvoker,
+ description: string,
+): StaticValue | null => {
+ if (!REGEXP_METHODS.has(method)) return null;
+ return runConcretely(toRegExp(target), method, callArguments, invoke) ?? unknown(description);
+};
diff --git a/packages/parser/src/analyze/symbols.ts b/packages/parser/src/analyze/symbols.ts
new file mode 100644
index 00000000..2f97ae8a
--- /dev/null
+++ b/packages/parser/src/analyze/symbols.ts
@@ -0,0 +1,184 @@
+import type { LinkedSymbol } from "../link/linker.js";
+import { isAnonymousFunctionDefinition } from "../module/ast.js";
+import {
+ type DeclarationBinding,
+ DEFAULT_EXPORT_NAME,
+ type ImportBinding,
+ NAMESPACE_IMPORT_NAME,
+ type ParsedModule,
+} from "../module/types.js";
+import { normalizeExternal } from "./access.js";
+import { classifyClass } from "./components.js";
+import { evaluateEnum } from "./enums.js";
+import type { Interpreter } from "./interpreter.js";
+import { bindPattern } from "./patterns.js";
+import { createScope, declareVariable, type Scope } from "./scope.js";
+import {
+ assignStatic,
+ forgetStatics,
+ nameValue,
+ type StaticValue,
+ UNDEFINED,
+ unknown,
+} from "./values.js";
+
+export const getModuleScope = (interpreter: Interpreter, module: ParsedModule): Scope => {
+ let scope = interpreter.moduleScopes.get(module);
+ if (!scope) {
+ scope = createScope(null);
+ interpreter.moduleScopes.set(module, scope);
+ }
+ return scope;
+};
+
+/**
+ * Members a module assigns to a function or component after declaring it
+ * (`Card.Header = Header`, `Form.displayName = "Form"`). They are folded
+ * once the binding is declared, so an initializer that refers back to the
+ * binding sees the value rather than a cycle.
+ */
+const applyAssignedStatics = (
+ interpreter: Interpreter,
+ module: ParsedModule,
+ localName: string,
+ value: StaticValue,
+): void => {
+ if (value.kind !== "function" && value.kind !== "component") return;
+ const { members, hasUntrackedWrites } = interpreter.linker.getMemberAssignments(
+ module,
+ localName,
+ );
+ for (const [key, assigned] of members) {
+ const staticValue = interpreter.evaluateExpression(
+ assigned,
+ interpreter.createModuleContext(module),
+ );
+ assignStatic(value, key, staticValue);
+ }
+ if (hasUntrackedWrites) forgetStatics(value);
+};
+
+const evaluateDeclaration = (
+ interpreter: Interpreter,
+ module: ParsedModule,
+ binding: DeclarationBinding,
+): StaticValue => {
+ const scope = getModuleScope(interpreter, module);
+ const context = interpreter.createModuleContext(module);
+ const node = binding.node;
+ switch (node.type) {
+ case "VariableDeclarator": {
+ if (!node.init) return unknown(`${binding.localName} declared without an initializer`);
+ const value = interpreter.evaluateExpression(node.init, context);
+ if (node.id.type === "Identifier") {
+ return nameValue(value, binding.localName, isAnonymousFunctionDefinition(node.init));
+ }
+ bindPattern(interpreter, node.id, value, context);
+ return scope.variables.get(binding.localName) ?? UNDEFINED;
+ }
+ case "ClassDeclaration":
+ case "ClassExpression":
+ return classifyClass(interpreter, node, module, scope, binding.localName, context);
+ case "TSEnumDeclaration":
+ return evaluateEnum(interpreter, node, context);
+ default:
+ return {
+ kind: "function",
+ fn: node,
+ module,
+ scope,
+ thisValue: null,
+ name: node.id?.name ?? binding.localName,
+ statics: new Map(),
+ hasUnknownStatics: false,
+ };
+ }
+};
+
+const importValue = (
+ interpreter: Interpreter,
+ module: ParsedModule,
+ binding: ImportBinding,
+): StaticValue => {
+ if (binding.isTypeOnly) return unknown(`type-only import ${binding.localName}`);
+ const value = valueFromSymbol(
+ interpreter,
+ interpreter.linker.resolveReference(module, [binding.localName]),
+ );
+ if (value.kind !== "external" || value.name !== null) return value;
+ const isNamedImport =
+ binding.importedName !== DEFAULT_EXPORT_NAME && binding.importedName !== NAMESPACE_IMPORT_NAME;
+ return { ...value, name: isNamedImport ? binding.importedName : binding.localName };
+};
+
+/**
+ * Value of a module's top-level binding, evaluated lazily in module scope
+ * and cached there. Cycles resolve to an unknown value instead of looping.
+ */
+export const resolveModuleBinding = (
+ interpreter: Interpreter,
+ module: ParsedModule,
+ name: string,
+): StaticValue | null => {
+ const scope = getModuleScope(interpreter, module);
+ const cached = scope.variables.get(name);
+ if (cached) return cached;
+ const binding = module.bindings.get(name);
+ if (!binding) return null;
+ declareVariable(scope, name, unknown(`cyclic reference to ${name}`));
+ const value =
+ binding.kind === "import"
+ ? importValue(interpreter, module, binding)
+ : evaluateDeclaration(interpreter, module, binding);
+ declareVariable(scope, name, value);
+ applyAssignedStatics(interpreter, module, name, value);
+ return value;
+};
+
+export const valueFromSymbol = (interpreter: Interpreter, symbol: LinkedSymbol): StaticValue => {
+ switch (symbol.kind) {
+ case "declaration":
+ return (
+ resolveModuleBinding(interpreter, symbol.module, symbol.localName) ??
+ unknown(`missing binding ${symbol.localName}`)
+ );
+ case "value": {
+ const cacheKey = `${symbol.module.filePath}@${symbol.node.start}`;
+ const cached = interpreter.valueCache.get(cacheKey);
+ if (cached) return cached;
+ interpreter.valueCache.set(cacheKey, unknown(`cyclic export ${symbol.exportedName}`));
+ const context = interpreter.createModuleContext(symbol.module);
+ const isNamedExport =
+ symbol.exportedName !== DEFAULT_EXPORT_NAME && symbol.exportedName !== "";
+ const value = nameValue(
+ interpreter.evaluateExpression(symbol.node, context),
+ isNamedExport ? symbol.exportedName : null,
+ isAnonymousFunctionDefinition(symbol.node),
+ );
+ interpreter.valueCache.set(cacheKey, value);
+ return value;
+ }
+ case "namespace":
+ return { kind: "namespace", module: symbol.module };
+ case "external":
+ return normalizeExternal({
+ kind: "external",
+ specifier: symbol.specifier,
+ packageName: symbol.packageName,
+ importedName: symbol.importedName,
+ memberPath: symbol.memberPath,
+ name: null,
+ });
+ case "unresolved":
+ return unknown(
+ `unresolved ${[symbol.name, ...symbol.memberPath].join(".")} (${symbol.reason})`,
+ );
+ }
+};
+
+export const getModuleExport = (
+ interpreter: Interpreter,
+ module: ParsedModule,
+ exportedName: string,
+): StaticValue =>
+ valueFromSymbol(interpreter, interpreter.linker.resolveExport(module, exportedName));
diff --git a/packages/parser/src/analyze/values.ts b/packages/parser/src/analyze/values.ts
new file mode 100644
index 00000000..2d0784d2
--- /dev/null
+++ b/packages/parser/src/analyze/values.ts
@@ -0,0 +1,646 @@
+import type { Expression, Span } from "@oxc-project/types";
+import type { StaticFiber } from "../fiber/types.js";
+import type { FunctionLike } from "../module/ast.js";
+import type { SourceLocation } from "../module/location.js";
+import type { ParsedModule } from "../module/types.js";
+import type { Scope } from "./scope.js";
+
+export type Primitive = string | number | boolean | bigint | symbol | null | undefined;
+
+/** A fully known primitive. */
+export interface LiteralValue {
+ kind: "literal";
+ value: Primitive;
+}
+
+/** A string whose contents are only known at runtime (template literal, `t()`). */
+export interface TextValue {
+ kind: "text";
+ description: string;
+}
+
+export interface UnknownValue {
+ kind: "unknown";
+ description: string;
+}
+
+/** A regular expression literal; matching is stateless, `lastIndex` is not tracked. */
+export interface RegExpValue {
+ kind: "regexp";
+ pattern: string;
+ flags: string;
+}
+
+export interface ArrayValue {
+ kind: "array";
+ items: StaticValue[];
+ /** Undecided control-flow depth the array was created at; mutations from deeper are conditional. */
+ depth: number;
+ /** Named members written onto the array, as `useInView` does with `result.ref = result[0]`. */
+ properties: Map;
+}
+
+/** Zero or more repetitions of `item`, the shape produced by `.map()`. */
+export interface ListValue {
+ kind: "list";
+ item: StaticValue;
+ description: string;
+ /** `flatMap()` output: an array item contributes its elements, not a fragment. */
+ isFlat: boolean;
+ /** Spread into an enclosing array (`[a, ...items.map(…)]`), so items are siblings of `a`. */
+ isInline: boolean;
+}
+
+export interface ConditionalValue {
+ kind: "conditional";
+ test: string;
+ whenTrue: StaticValue;
+ whenFalse: StaticValue;
+}
+
+/**
+ * An array item that is present only when `test` passes at runtime: what
+ * `.filter()` keeps when its predicate cannot be decided. Unlike a
+ * conditional with an `undefined` arm, callbacks over the array never see
+ * the absent case and the array's length and indices become unknown.
+ */
+export interface OptionalValue {
+ kind: "optional";
+ test: string;
+ value: StaticValue;
+}
+
+export interface ObjectValue {
+ kind: "object";
+ properties: Map;
+ /** An unknown object was spread in, so absent keys may still exist. */
+ hasUnknownSpread: boolean;
+ /** Undecided control-flow depth the object was created at; mutations from deeper are conditional. */
+ depth: number;
+}
+
+/** A closure: the function together with the scope it was created in. */
+export interface FunctionValue {
+ kind: "function";
+ fn: FunctionLike;
+ module: ParsedModule;
+ scope: Scope;
+ /** `this` captured for class methods; `null` for plain functions. */
+ thisValue: StaticValue | null;
+ /** Inferred name for display, e.g. `renderHeader` for `const renderHeader = () => …`. */
+ name: string | null;
+ /** Properties assigned on the function object itself (`Card.Header = Header`). */
+ statics: Map;
+ /** Written with keys or by code the analysis did not follow, so a key `statics` lacks may exist. */
+ hasUnknownStatics: boolean;
+}
+
+export interface ComponentValue {
+ kind: "component";
+ definition: ComponentDefinition;
+ /** Properties assigned on the component object itself (`Sidebar.Tabs = Tabs`). */
+ statics: Map;
+ /** Written with keys or by code the analysis did not follow, so a key `statics` lacks may exist. */
+ hasUnknownStatics: boolean;
+}
+
+export interface ElementValue {
+ kind: "element";
+ type: StaticValue;
+ key: StaticValue | null;
+ props: ObjectValue;
+ location: SourceLocation | null;
+ /** Fiber whose render produced this element; `null` at the root. */
+ owner: StaticFiber | null;
+}
+
+/** The exports of a parsed module, as produced by `import * as ns` or `import()`. */
+export interface NamespaceValue {
+ kind: "namespace";
+ module: ParsedModule;
+}
+
+/** A binding imported from a package outside the analyzed graph. */
+export interface ExternalValue {
+ kind: "external";
+ specifier: string;
+ packageName: string | null;
+ /** `default`, `*` or the exported name imported from the package. */
+ importedName: string;
+ memberPath: string[];
+ /** Best display name: the local import name followed by accessed members. */
+ name: string | null;
+}
+
+/**
+ * A standard global (`Object.assign`, `Array.prototype.slice`, `Math`) held
+ * as a value, as when `_extends = Object.assign || …` stores it for later.
+ */
+export interface GlobalValue {
+ kind: "global";
+ chain: string[];
+ typeName: "function" | "object";
+}
+
+export type StaticValue =
+ | LiteralValue
+ | TextValue
+ | UnknownValue
+ | RegExpValue
+ | ArrayValue
+ | ListValue
+ | ConditionalValue
+ | OptionalValue
+ | ObjectValue
+ | FunctionValue
+ | ComponentValue
+ | ElementValue
+ | NamespaceValue
+ | ExternalValue
+ | GlobalValue;
+
+export type BuiltinComponentName =
+ | "Fragment"
+ | "Suspense"
+ | "SuspenseList"
+ | "StrictMode"
+ | "Profiler"
+ | "Activity"
+ | "ViewTransition"
+ | "Portal";
+
+export interface ClassFunctionMember {
+ key: string;
+ isStatic: boolean;
+ kind: "constructor" | "method" | "getter";
+ fn: FunctionLike;
+}
+
+export interface ClassFieldMember {
+ key: string;
+ isStatic: boolean;
+ kind: "field";
+ /** The initializer; `null` when the field is declared without one. */
+ value: Expression | null;
+}
+
+/**
+ * One member of a class component, whether written as class syntax or as
+ * the prototype and static assignments a compiler lowers a class to.
+ */
+export type ClassMember = ClassFunctionMember | ClassFieldMember;
+
+export interface ClassComponentDefinition {
+ kind: "class";
+ name: string | null;
+ module: ParsedModule;
+ members: ClassMember[];
+ /** Scope the members close over: the module, or the wrapper a compiler emitted. */
+ scope: Scope;
+ /** Project-local class component this one extends and inherits members from. */
+ base: ClassComponentDefinition | null;
+ /** `static defaultProps`, resolved into props the way `createElement` does for classes. */
+ defaultProps: ObjectValue | null;
+ /** `static contextType`, read into `this.context`. */
+ contextType: StaticValue | null;
+ isErrorBoundary: boolean;
+ span: Span;
+}
+
+export interface MemoComponentDefinition {
+ kind: "memo";
+ name: string | null;
+ inner: StaticValue;
+ hasCompare: boolean;
+ span: Span;
+}
+
+export interface ForwardRefComponentDefinition {
+ kind: "forwardRef";
+ name: string | null;
+ render: FunctionValue | null;
+ span: Span;
+}
+
+export interface LazyComponentDefinition {
+ kind: "lazy";
+ name: string | null;
+ inner: StaticValue;
+ span: Span;
+}
+
+export interface ContextDefinition {
+ kind: "context";
+ name: string | null;
+ role: "provider" | "consumer";
+ defaultValue: StaticValue;
+ /** Module and span of the `createContext` call; together they identify the context. */
+ module: ParsedModule;
+ span: Span;
+}
+
+export interface BuiltinComponentDefinition {
+ kind: "builtin";
+ name: BuiltinComponentName;
+}
+
+export type ComponentDefinition =
+ | ClassComponentDefinition
+ | MemoComponentDefinition
+ | ForwardRefComponentDefinition
+ | LazyComponentDefinition
+ | ContextDefinition
+ | BuiltinComponentDefinition;
+
+export const literal = (value: Primitive): LiteralValue => ({ kind: "literal", value });
+export const text = (description: string): TextValue => ({ kind: "text", description });
+export const unknown = (description: string): UnknownValue => ({ kind: "unknown", description });
+export const regexp = (pattern: string, flags: string): RegExpValue => ({
+ kind: "regexp",
+ pattern,
+ flags,
+});
+/** A fresh `RegExp` for one match, so shared values never observe each other's `lastIndex`. */
+export const toRegExp = (value: RegExpValue): RegExp => new RegExp(value.pattern, value.flags);
+export const array = (items: StaticValue[], depth = 0): ArrayValue => ({
+ kind: "array",
+ items,
+ depth,
+ properties: new Map(),
+});
+export const list = (item: StaticValue, description: string, isFlat = false): ListValue => ({
+ kind: "list",
+ item,
+ description,
+ isFlat,
+ isInline: false,
+});
+const assumeOutcome = (value: StaticValue, test: string, outcome: boolean): StaticValue => {
+ if (value.kind !== "conditional") return value;
+ if (value.test === test)
+ return assumeOutcome(outcome ? value.whenTrue : value.whenFalse, test, outcome);
+ const whenTrue = assumeOutcome(value.whenTrue, test, outcome);
+ const whenFalse = assumeOutcome(value.whenFalse, test, outcome);
+ return whenTrue === value.whenTrue && whenFalse === value.whenFalse
+ ? value
+ : { ...value, whenTrue, whenFalse };
+};
+
+/** Rewrites `value` knowing that `test` evaluated to `outcome` on this path. */
+export const assumeTest = (value: StaticValue, test: string, outcome: boolean): StaticValue => {
+ const operand = getNegatedOperand(test);
+ return operand === null
+ ? assumeOutcome(value, test, outcome)
+ : assumeOutcome(value, operand, !outcome);
+};
+
+const isSameLiteral = (left: StaticValue, right: StaticValue): boolean =>
+ left.kind === "literal" && right.kind === "literal" && Object.is(left.value, right.value);
+
+const isWrappedInParentheses = (source: string): boolean => {
+ if (!source.startsWith("(") || !source.endsWith(")")) return false;
+ let depth = 0;
+ for (let index = 0; index < source.length; index++) {
+ if (source[index] === "(") depth++;
+ else if (source[index] === ")" && --depth === 0) return index === source.length - 1;
+ }
+ return false;
+};
+
+/** The operand of a test that negates one thing: `x` for `!x` and `!(x && y)`, `null` otherwise. */
+const getNegatedOperand = (test: string): string | null => {
+ if (!test.startsWith("!")) return null;
+ const operand = test.slice(1);
+ if (/^[\w$.]+$/.test(operand)) return operand;
+ return isWrappedInParentheses(operand) ? operand.slice(1, -1) : null;
+};
+
+/**
+ * A value that depends on `test`. Within a render the same test expression
+ * has one outcome, so nested conditionals on it collapse; identical arms
+ * collapse to the value itself. A negated test is stored as the positive
+ * one with its arms swapped, so `x` and `!x` share an outcome.
+ */
+export const conditional = (
+ test: string,
+ whenTrue: StaticValue,
+ whenFalse: StaticValue,
+): StaticValue => {
+ const operand = getNegatedOperand(test);
+ if (operand !== null) return conditional(operand, whenFalse, whenTrue);
+ const assumedTrue = assumeOutcome(whenTrue, test, true);
+ const assumedFalse = assumeOutcome(whenFalse, test, false);
+ if (assumedTrue === assumedFalse || isSameLiteral(assumedTrue, assumedFalse)) return assumedTrue;
+ return { kind: "conditional", test, whenTrue: assumedTrue, whenFalse: assumedFalse };
+};
+/** Nested optionals are one item that needs every test to pass. */
+export const optional = (test: string, value: StaticValue): OptionalValue =>
+ value.kind === "optional"
+ ? { kind: "optional", test: `${value.test} && ${test}`, value: value.value }
+ : { kind: "optional", test, value };
+/** An optional item read on its own, e.g. as a spread argument: it is `undefined` when absent. */
+export const readItem = (value: StaticValue): StaticValue =>
+ value.kind === "optional" ? conditional(value.test, value.value, UNDEFINED) : value;
+
+/** Beyond this many undecided items, selecting one is not worth the branching. */
+export const SELECTION_LIMIT = 8;
+
+/**
+ * The item at `position` once absent items are skipped, so `filtered[0]`
+ * reads as the first item that is present. Each optional item before the
+ * position contributes one branch on its presence test.
+ */
+export const selectItem = (items: StaticValue[], position: number): StaticValue => {
+ const firstOptional = items.findIndex((item) => item.kind === "optional");
+ if (firstOptional === -1 || firstOptional > position) return items[position] ?? UNDEFINED;
+ if (items.length > SELECTION_LIMIT) return unknown(`array[${position}]`);
+ const select = (start: number, remaining: number): StaticValue => {
+ const item = items[start];
+ if (item === undefined) return UNDEFINED;
+ if (item.kind !== "optional") {
+ return remaining === 0 ? item : select(start + 1, remaining - 1);
+ }
+ return conditional(
+ item.test,
+ remaining === 0 ? item.value : select(start + 1, remaining - 1),
+ select(start + 1, remaining),
+ );
+ };
+ return select(0, position);
+};
+export const object = (
+ properties: Iterable<[string, StaticValue]> = [],
+ hasUnknownSpread = false,
+ depth = 0,
+): ObjectValue => ({ kind: "object", properties: new Map(properties), hasUnknownSpread, depth });
+export const component = (
+ definition: ComponentDefinition,
+ statics: Map = new Map(),
+ hasUnknownStatics = false,
+): ComponentValue => ({
+ kind: "component",
+ definition,
+ statics,
+ hasUnknownStatics,
+});
+export const builtin = (name: BuiltinComponentName): ComponentValue =>
+ component({ kind: "builtin", name });
+
+export const UNDEFINED = literal(undefined);
+export const NULL = literal(null);
+export const TRUE = literal(true);
+export const FALSE = literal(false);
+
+export const isNullish = (value: Primitive): value is null | undefined =>
+ value === null || value === undefined;
+
+export const isNullishValue = (value: StaticValue): boolean =>
+ value.kind === "literal" && isNullish(value.value);
+
+/** Leaves of a conditional tree; one for any other value. */
+export const countArms = (value: StaticValue): number =>
+ value.kind === "conditional" ? countArms(value.whenTrue) + countArms(value.whenFalse) : 1;
+
+/** Applies `transform` to every arm of a conditional, keeping its branch structure. */
+export const mapConditional = (
+ value: StaticValue,
+ transform: (arm: StaticValue) => StaticValue,
+): StaticValue =>
+ value.kind === "conditional"
+ ? conditional(
+ value.test,
+ mapConditional(value.whenTrue, transform),
+ mapConditional(value.whenFalse, transform),
+ )
+ : transform(value);
+
+export const isRenderedAsText = (value: Primitive): boolean =>
+ (typeof value === "string" && value !== "") ||
+ typeof value === "number" ||
+ typeof value === "bigint";
+
+export const getObjectProperty = (value: ObjectValue, key: string): StaticValue =>
+ value.properties.get(key) ??
+ (value.hasUnknownSpread ? unknown(`spread property "${key}"`) : UNDEFINED);
+
+export const mergeObjects = (target: ObjectValue, source: ObjectValue): void => {
+ for (const [key, propertyValue] of source.properties) target.properties.set(key, propertyValue);
+ if (source.hasUnknownSpread) target.hasUnknownSpread = true;
+};
+
+export const cloneObject = (value: ObjectValue): ObjectValue =>
+ object(value.properties, value.hasUnknownSpread);
+
+/**
+ * Applies the name a value is bound to, mirroring the runtime's
+ * `Function.name` inference: `const Header = () => …` names the arrow, but
+ * `const Header = memo(() => …)` or `const Header = parts.header` leaves the
+ * function as it was, so `isDefinition` says whether the binding's
+ * initializer was the anonymous function or class itself. Contexts are
+ * named for display; the runtime has no name for them either way.
+ */
+export const nameValue = (
+ value: StaticValue,
+ name: string | null,
+ isDefinition: boolean,
+): StaticValue => {
+ if (name === null) return value;
+ if (value.kind === "function") {
+ return isDefinition && value.name === null ? { ...value, name } : value;
+ }
+ if (value.kind !== "component") return value;
+ const definition = value.definition;
+ const isNameable = definition.kind === "context" || (definition.kind === "class" && isDefinition);
+ return isNameable && definition.name === null
+ ? component({ ...definition, name }, value.statics, value.hasUnknownStatics)
+ : value;
+};
+
+/**
+ * `Target.key = value` on a function or component, as written for compound
+ * components and static config. `displayName` is folded into the name the
+ * way the runtime prefers it, and the statics React reads off a class into
+ * its definition.
+ */
+export const assignStatic = (target: StaticValue, key: string, value: StaticValue): void => {
+ if (target.kind !== "function" && target.kind !== "component") return;
+ if (key === "displayName" || (key === "name" && target.kind === "function")) {
+ if (value.kind !== "literal" || typeof value.value !== "string") return;
+ const name = value.value || null;
+ if (target.kind === "function") {
+ if (key === "name" || name !== null) target.name = name;
+ } else if (target.definition.kind !== "builtin" && name !== null) {
+ target.definition.name = name;
+ }
+ return;
+ }
+ if (target.kind === "component" && target.definition.kind === "class") {
+ if (assignClassStatic(target.definition, key, value)) return;
+ }
+ target.statics.set(key, value);
+};
+
+/** A write with a key or through code the analysis did not follow: absent statics are no longer `undefined`. */
+export const forgetStatics = (target: StaticValue): void => {
+ if (target.kind === "function" || target.kind === "component") target.hasUnknownStatics = true;
+};
+
+/** The statics React reads off a class itself, assigned after the class the way compilers emit them. */
+const assignClassStatic = (
+ definition: ClassComponentDefinition,
+ key: string,
+ value: StaticValue,
+): boolean => {
+ switch (key) {
+ case "defaultProps":
+ if (value.kind !== "object") return false;
+ definition.defaultProps = value;
+ return true;
+ case "contextType":
+ definition.contextType = value;
+ return true;
+ case "getDerivedStateFromError":
+ definition.isErrorBoundary = true;
+ return false;
+ default:
+ return false;
+ }
+};
+
+const isFullyKnownInner = (value: StaticValue, visited: Set): boolean => {
+ switch (value.kind) {
+ case "literal":
+ case "regexp":
+ case "function":
+ case "component":
+ case "namespace":
+ case "external":
+ case "global":
+ return true;
+ case "text":
+ case "unknown":
+ case "list":
+ case "conditional":
+ case "optional":
+ return false;
+ case "array":
+ if (visited.has(value)) return true;
+ visited.add(value);
+ return value.items.every((item) => isFullyKnownInner(item, visited));
+ case "object":
+ if (visited.has(value)) return true;
+ visited.add(value);
+ return (
+ !value.hasUnknownSpread &&
+ [...value.properties.values()].every((property) => isFullyKnownInner(property, visited))
+ );
+ case "element":
+ return (
+ isFullyKnownInner(value.type, visited) &&
+ (value.key === null || isFullyKnownInner(value.key, visited)) &&
+ isFullyKnownInner(value.props, visited)
+ );
+ }
+};
+
+/**
+ * Whether no part of `value` stands in for a runtime value. Computation on
+ * fully known input can be followed to its result; anything else can only be
+ * approximated.
+ */
+export const isFullyKnown = (value: StaticValue): boolean => isFullyKnownInner(value, new Set());
+
+/** Truthiness when statically decidable, otherwise `null`. */
+export const getTruthiness = (value: StaticValue): boolean | null => {
+ switch (value.kind) {
+ case "literal":
+ return Boolean(value.value);
+ case "regexp":
+ case "array":
+ case "object":
+ case "function":
+ case "component":
+ case "element":
+ case "list":
+ case "namespace":
+ case "external":
+ case "global":
+ return true;
+ case "text":
+ case "unknown":
+ return null;
+ case "conditional": {
+ const whenTrue = getTruthiness(value.whenTrue);
+ const whenFalse = getTruthiness(value.whenFalse);
+ return whenTrue !== null && whenTrue === whenFalse ? whenTrue : null;
+ }
+ case "optional":
+ return getTruthiness(value.value) === false ? false : null;
+ }
+};
+
+export const getComponentName = (definition: ComponentDefinition): string | null => {
+ switch (definition.kind) {
+ case "builtin":
+ return definition.name;
+ case "memo":
+ return definition.name ?? getValueName(definition.inner);
+ case "lazy":
+ return definition.name ?? getValueName(definition.inner);
+ case "forwardRef":
+ return definition.name ?? definition.render?.name ?? null;
+ default:
+ return definition.name;
+ }
+};
+
+/** Name a value would be displayed with when used as an element type. */
+export const getValueName = (value: StaticValue): string | null => {
+ switch (value.kind) {
+ case "literal":
+ return typeof value.value === "string" ? value.value : null;
+ case "function":
+ return value.name;
+ case "component":
+ return getComponentName(value.definition);
+ case "external":
+ return value.name;
+ default:
+ return null;
+ }
+};
+
+export const describeValue = (value: StaticValue): string => {
+ switch (value.kind) {
+ case "literal":
+ return typeof value.value === "string" ? JSON.stringify(value.value) : String(value.value);
+ case "text":
+ return `text(${value.description})`;
+ case "unknown":
+ return `unknown(${value.description})`;
+ case "regexp":
+ return `/${value.pattern}/${value.flags}`;
+ case "array":
+ return `[${value.items.map(describeValue).join(", ")}]`;
+ case "list":
+ return `list(${value.description})`;
+ case "conditional":
+ return `(${value.test} ? ${describeValue(value.whenTrue)} : ${describeValue(value.whenFalse)})`;
+ case "optional":
+ return `(${value.test} ? ${describeValue(value.value)} : absent)`;
+ case "object":
+ return `{${[...value.properties.keys()].join(", ")}${value.hasUnknownSpread ? ", ..." : ""}}`;
+ case "function":
+ return `fn(${value.name ?? "anonymous"})`;
+ case "component":
+ return `component(${getComponentName(value.definition) ?? value.definition.kind})`;
+ case "element":
+ return `<${getValueName(value.type) ?? describeValue(value.type)}>`;
+ case "namespace":
+ return `namespace(${value.module.filePath})`;
+ case "external":
+ return `external(${value.specifier}:${[value.importedName, ...value.memberPath].join(".")})`;
+ case "global":
+ return `global(${value.chain.join(".")})`;
+ }
+};
diff --git a/packages/parser/src/corpus/browser.ts b/packages/parser/src/corpus/browser.ts
new file mode 100644
index 00000000..b8cda44e
--- /dev/null
+++ b/packages/parser/src/corpus/browser.ts
@@ -0,0 +1,67 @@
+import { chromium } from "@playwright/test";
+import type { FiberSnapshot } from "../snapshot/types.js";
+
+export interface RuntimeCapture {
+ roots: FiberSnapshot[];
+ commitCount: number;
+ /** `console.error` output and uncaught page errors, for the report. */
+ pageErrors: string[];
+}
+
+export interface CaptureOptions {
+ /** Path of the bundled `capture.ts`. */
+ captureScriptPath: string;
+ /** Milliseconds to wait for React's first commit. */
+ firstCommitTimeoutMs: number;
+ /** Milliseconds without a new commit before the tree counts as settled. */
+ settleMs: number;
+ /** Upper bound on waiting for the tree to settle. */
+ maxSettleMs: number;
+}
+
+const SETTLE_POLL_MS = 250;
+
+const sleep = (milliseconds: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+/**
+ * Opens `url` in headless Chromium with the capture script installed before
+ * any page script runs, waits for React to commit and go quiet, and returns
+ * the committed fiber trees.
+ */
+export const captureRuntimeTree = async (
+ url: string,
+ options: CaptureOptions,
+): Promise => {
+ const browser = await chromium.launch();
+ try {
+ const page = await browser.newPage();
+ const pageErrors: string[] = [];
+ page.on("pageerror", (error) => pageErrors.push(`pageerror: ${error.message}`));
+ page.on("console", (message) => {
+ if (message.type() === "error") pageErrors.push(`console.error: ${message.text()}`);
+ });
+ await page.addInitScript({ path: options.captureScriptPath });
+ await page.goto(url, { waitUntil: "load", timeout: options.firstCommitTimeoutMs });
+ await page.waitForFunction(() => window.__BIPPY_PARSER_CAPTURE__.getCommitCount() > 0, null, {
+ timeout: options.firstCommitTimeoutMs,
+ });
+
+ const settleDeadline = Date.now() + options.maxSettleMs;
+ let commitCount = await page.evaluate(() => window.__BIPPY_PARSER_CAPTURE__.getCommitCount());
+ let quietSince = Date.now();
+ while (Date.now() < settleDeadline && Date.now() - quietSince < options.settleMs) {
+ await sleep(SETTLE_POLL_MS);
+ const latest = await page.evaluate(() => window.__BIPPY_PARSER_CAPTURE__.getCommitCount());
+ if (latest !== commitCount) {
+ commitCount = latest;
+ quietSince = Date.now();
+ }
+ }
+
+ const roots = await page.evaluate(() => window.__BIPPY_PARSER_CAPTURE__.snapshot());
+ return { roots, commitCount, pageErrors };
+ } finally {
+ await browser.close();
+ }
+};
diff --git a/packages/parser/src/corpus/bundle.ts b/packages/parser/src/corpus/bundle.ts
new file mode 100644
index 00000000..64f0358c
--- /dev/null
+++ b/packages/parser/src/corpus/bundle.ts
@@ -0,0 +1,50 @@
+import { existsSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { build } from "vite-plus";
+
+const corpusDirectory = dirname(fileURLToPath(import.meta.url));
+const bippySourceDirectory = resolve(corpusDirectory, "../../../bippy/src");
+
+export const CAPTURE_SCRIPT_NAME = "capture.js";
+
+/**
+ * Bundles `capture.ts` with Bippy into a self-contained script. Bippy's
+ * `react.ts` entry imports React itself, which must not end up in the page
+ * twice, so the bundle maps `bippy` to the hook-only core module.
+ */
+export const buildCaptureScript = async (outputDirectory: string): Promise => {
+ const outputPath = join(outputDirectory, CAPTURE_SCRIPT_NAME);
+ await build({
+ configFile: false,
+ logLevel: "error",
+ resolve: {
+ alias: [
+ { find: /^bippy$/, replacement: join(bippySourceDirectory, "core.ts") },
+ {
+ find: "bippy/install-hook-only",
+ replacement: join(bippySourceDirectory, "install-hook-only.ts"),
+ },
+ ],
+ },
+ define: {
+ "process.env.NODE_ENV": JSON.stringify("development"),
+ "process.env.VERSION": JSON.stringify("parser-corpus"),
+ },
+ build: {
+ lib: {
+ entry: join(corpusDirectory, "capture.ts"),
+ formats: ["iife"],
+ name: "BippyParserCapture",
+ fileName: () => CAPTURE_SCRIPT_NAME,
+ },
+ outDir: outputDirectory,
+ emptyOutDir: false,
+ minify: false,
+ sourcemap: false,
+ target: "es2020",
+ },
+ });
+ if (!existsSync(outputPath)) throw new Error(`capture bundle was not written to ${outputPath}`);
+ return outputPath;
+};
diff --git a/packages/parser/src/corpus/capture.ts b/packages/parser/src/corpus/capture.ts
new file mode 100644
index 00000000..3298711f
--- /dev/null
+++ b/packages/parser/src/corpus/capture.ts
@@ -0,0 +1,36 @@
+import "bippy/install-hook-only";
+import { type FiberRoot, instrument } from "bippy";
+import { snapshotRuntimeFiber } from "../harness/runtime-snapshot.js";
+import type { FiberSnapshot } from "../snapshot/types.js";
+
+/**
+ * Browser side of live verification. Bundled and injected as an init script
+ * so the DevTools hook exists before the page's React DOM registers, then
+ * queried from Playwright once the app has settled.
+ */
+export interface CaptureGlobal {
+ getCommitCount: () => number;
+ /** One snapshot per root React has committed to, in first-commit order. */
+ snapshot: () => FiberSnapshot[];
+}
+
+declare global {
+ interface Window {
+ __BIPPY_PARSER_CAPTURE__: CaptureGlobal;
+ }
+}
+
+const roots = new Set();
+let commitCount = 0;
+
+instrument({
+ onCommitFiberRoot: (_rendererId, root) => {
+ roots.add(root);
+ commitCount++;
+ },
+});
+
+window.__BIPPY_PARSER_CAPTURE__ = {
+ getCommitCount: () => commitCount,
+ snapshot: () => [...roots].map((root) => snapshotRuntimeFiber(root.current)),
+};
diff --git a/packages/parser/src/corpus/checkout.ts b/packages/parser/src/corpus/checkout.ts
new file mode 100644
index 00000000..912891c6
--- /dev/null
+++ b/packages/parser/src/corpus/checkout.ts
@@ -0,0 +1,84 @@
+import { existsSync, mkdirSync } from "node:fs";
+import { join } from "node:path";
+import { runCommand } from "./process.js";
+import {
+ type CorpusCheckout,
+ type CorpusRepository,
+ getRepositoryDirectoryName,
+ getRepositoryUrl,
+} from "./repositories.js";
+import { linkWorkspacePackages } from "./workspaces.js";
+
+export interface CheckoutOptions {
+ /** Directory that holds one clone per repository. */
+ cacheDirectory: string;
+ /** Directory that holds one set of workspace package links per repository. */
+ linksDirectory: string;
+ /** Fetch the branch tip again for clones that already exist. */
+ update: boolean;
+ /** Fail instead of cloning when a repository is missing from the cache. */
+ offline: boolean;
+}
+
+const CLONE_TIMEOUT_MS = 20 * 60_000;
+
+const git = (args: string[], cwd: string): Promise =>
+ runCommand("git", args, {
+ cwd,
+ timeoutMs: CLONE_TIMEOUT_MS,
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" },
+ });
+
+/**
+ * Shallow clone of the default branch; only the working tree matters for
+ * analysis, so history is not fetched. Dependencies are not installed, but
+ * workspace packages are linked so cross-package imports resolve to source.
+ * Returns the checkout together with the commit that was analyzed, for
+ * reproducible reports.
+ */
+export const checkoutRepository = async (
+ repository: CorpusRepository,
+ options: CheckoutOptions,
+): Promise => {
+ mkdirSync(options.cacheDirectory, { recursive: true });
+ const directoryName = getRepositoryDirectoryName(repository);
+ const rootDirectory = join(options.cacheDirectory, directoryName);
+ const exists = existsSync(join(rootDirectory, ".git"));
+ if (!exists) {
+ if (options.offline) throw new Error(`${repository.slug} is not in ${options.cacheDirectory}`);
+ await git(
+ [
+ "clone",
+ "--depth",
+ "1",
+ "--single-branch",
+ "--branch",
+ repository.defaultBranch,
+ "--no-tags",
+ getRepositoryUrl(repository),
+ rootDirectory,
+ ],
+ options.cacheDirectory,
+ );
+ } else if (options.update && !options.offline) {
+ await git(["fetch", "--depth", "1", "origin", repository.defaultBranch], rootDirectory);
+ await git(["reset", "--hard", "FETCH_HEAD"], rootDirectory);
+ await git(["clean", "-fdx", "--exclude=node_modules", "--exclude=.env*"], rootDirectory);
+ }
+ const commit = (await git(["rev-parse", "HEAD"], rootDirectory)).trim();
+ const linkedModules = linkWorkspacePackages(
+ rootDirectory,
+ join(options.linksDirectory, directoryName),
+ );
+ return {
+ name: repository.slug,
+ rootDirectory,
+ appDirectory: repository.appDirectory,
+ entryFiles: repository.entryFiles,
+ framework: repository.framework,
+ reactVersion: repository.reactVersion,
+ live: repository.live,
+ commit,
+ moduleDirectories: linkedModules ? [linkedModules] : [],
+ };
+};
diff --git a/packages/parser/src/corpus/dev-server.ts b/packages/parser/src/corpus/dev-server.ts
new file mode 100644
index 00000000..dd0ff4ee
--- /dev/null
+++ b/packages/parser/src/corpus/dev-server.ts
@@ -0,0 +1,99 @@
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { type BackgroundProcess, runShell, startBackgroundProcess } from "./process.js";
+import type { CorpusCheckout, LiveTarget } from "./repositories.js";
+
+export interface DevServer {
+ url: string;
+ stop: () => Promise;
+ getOutput: () => string;
+}
+
+const INSTALL_TIMEOUT_MS = 30 * 60_000;
+const POLL_INTERVAL_MS = 500;
+
+/** Environment that keeps dev servers from opening browsers or waiting on a TTY. */
+const SERVER_ENVIRONMENT: NodeJS.ProcessEnv = {
+ ...process.env,
+ BROWSER: "none",
+ CI: "1",
+ FORCE_COLOR: "0",
+ NO_COLOR: "1",
+ NEXT_TELEMETRY_DISABLED: "1",
+ DO_NOT_TRACK: "1",
+};
+
+const sleep = (milliseconds: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds));
+
+const isServing = async (url: string): Promise => {
+ try {
+ const response = await fetch(url, { signal: AbortSignal.timeout(POLL_INTERVAL_MS * 4) });
+ return response.status < 500;
+ } catch {
+ return false;
+ }
+};
+
+const waitUntilServing = async (
+ url: string,
+ server: BackgroundProcess,
+ timeoutMs: number,
+): Promise => {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (server.process.exitCode !== null) {
+ throw new Error(
+ `dev server exited with code ${server.process.exitCode} before serving ${url}\n${server.getOutput()}`,
+ );
+ }
+ if (await isServing(url)) return;
+ await sleep(POLL_INTERVAL_MS);
+ }
+ throw new Error(`dev server did not serve ${url} within ${timeoutMs}ms\n${server.getOutput()}`);
+};
+
+/** Runs the target's install command unless its dependencies are already present. */
+export const installDependencies = async (
+ checkout: CorpusCheckout,
+ target: LiveTarget,
+ onLog?: (message: string) => void,
+): Promise => {
+ if (target.installCommand === null) return;
+ const installedMarkers = [
+ join(checkout.rootDirectory, "node_modules"),
+ join(checkout.rootDirectory, checkout.appDirectory, "node_modules"),
+ ];
+ if (installedMarkers.some((marker) => existsSync(marker))) {
+ onLog?.("dependencies already installed");
+ return;
+ }
+ onLog?.(`installing: ${target.installCommand}`);
+ await runShell(target.installCommand, {
+ cwd: checkout.rootDirectory,
+ env: SERVER_ENVIRONMENT,
+ timeoutMs: INSTALL_TIMEOUT_MS,
+ });
+};
+
+export const getLiveUrl = (target: LiveTarget): string =>
+ `http://127.0.0.1:${target.port}${target.path ?? "/"}`;
+
+/** Starts the target's dev server and resolves once it answers HTTP requests. */
+export const startDevServer = async (
+ checkout: CorpusCheckout,
+ target: LiveTarget,
+): Promise => {
+ const url = getLiveUrl(target);
+ const server = startBackgroundProcess(target.devCommand, {
+ cwd: checkout.rootDirectory,
+ env: { ...SERVER_ENVIRONMENT, ...target.environment },
+ });
+ try {
+ await waitUntilServing(url, server, target.readyTimeoutMs);
+ } catch (error) {
+ await server.stop();
+ throw error;
+ }
+ return { url, stop: server.stop, getOutput: server.getOutput };
+};
diff --git a/packages/parser/src/corpus/index.ts b/packages/parser/src/corpus/index.ts
new file mode 100644
index 00000000..ccf38eeb
--- /dev/null
+++ b/packages/parser/src/corpus/index.ts
@@ -0,0 +1,11 @@
+export * from "./browser.js";
+export * from "./bundle.js";
+export * from "./checkout.js";
+export * from "./dev-server.js";
+export * from "./live.js";
+export * from "./report.js";
+export * from "./repositories.js";
+export * from "./scan.js";
+export * from "./sources.js";
+export * from "./workspace-apps.js";
+export * from "./workspaces.js";
diff --git a/packages/parser/src/corpus/live.ts b/packages/parser/src/corpus/live.ts
new file mode 100644
index 00000000..7ec78134
--- /dev/null
+++ b/packages/parser/src/corpus/live.ts
@@ -0,0 +1,137 @@
+import { join } from "node:path";
+import type { MountApi } from "../analyze/mount.js";
+import { type VerificationReport, verifySnapshots } from "../harness/verify.js";
+import { createStaticRenderer, type StaticRenderResult } from "../renderer.js";
+import type { FiberSnapshot, NodeSnapshot } from "../snapshot/types.js";
+import { type CaptureOptions, captureRuntimeTree } from "./browser.js";
+import { installDependencies, startDevServer } from "./dev-server.js";
+import type { CorpusCheckout, LiveTarget } from "./repositories.js";
+
+export interface LiveOptions {
+ captureScriptPath: string;
+ /** Link into `node_modules`, which live targets have installed. */
+ followExternalModules: boolean;
+ /** Fiber budget for the static tree. */
+ maxFiberCount: number;
+ /** Wall-clock budget for the static tree. */
+ timeBudgetMs: number;
+ onLog?: (message: string) => void;
+}
+
+export interface LiveVerification {
+ url: string;
+ entryFile: string;
+ /** The react-dom API the static root was found through; `null` when none was found. */
+ mountApi: MountApi | null;
+ runtimeRootCount: number;
+ commitCount: number;
+ /** Comparison of the static root against the largest runtime root. */
+ report: VerificationReport | null;
+ /** Analyzer diagnostics raised while building the static tree. */
+ diagnosticCount: number;
+ pageErrors: string[];
+ error: string | null;
+ durationMs: number;
+}
+
+const CAPTURE_TIMING: Omit = {
+ firstCommitTimeoutMs: 60_000,
+ settleMs: 2_000,
+ maxSettleMs: 20_000,
+};
+
+const countFibers = (nodes: NodeSnapshot[]): number => {
+ let total = 0;
+ for (const node of nodes) {
+ if (node.kind !== "fiber") continue;
+ total += 1 + countFibers(node.children);
+ }
+ return total;
+};
+
+/** The app's root: React also commits to roots such as portals and devtools overlays. */
+const pickLargestRoot = (roots: FiberSnapshot[]): FiberSnapshot | null =>
+ roots.reduce(
+ (largest, root) =>
+ largest === null || countFibers([root]) > countFibers([largest]) ? root : largest,
+ null,
+ );
+
+const getErrorMessage = (error: unknown): string =>
+ error instanceof Error ? error.message : String(error);
+
+const renderStaticRoot = (
+ checkout: CorpusCheckout,
+ target: LiveTarget,
+ options: LiveOptions,
+): { result: StaticRenderResult; mountApi: MountApi } => {
+ const renderer = createStaticRenderer({
+ rootDirectory: checkout.rootDirectory,
+ moduleDirectories: checkout.moduleDirectories,
+ followExternalModules: options.followExternalModules,
+ build: { maxFiberCount: options.maxFiberCount },
+ timeBudgetMs: options.timeBudgetMs,
+ });
+ const entryPath = join(checkout.rootDirectory, target.entryFile);
+ const [mount] = renderer.findMountPoints(entryPath);
+ if (!mount) throw new Error(`${target.entryFile} has no createRoot/hydrateRoot mount`);
+ return { result: renderer.renderValue(mount.element), mountApi: mount.api };
+};
+
+/**
+ * Boots the checkout's dev server, captures what React committed in a real
+ * browser and compares it with the tree derived from the entry module's
+ * mount call. Failures at any step are reported, not thrown.
+ */
+export const verifyLive = async (
+ checkout: CorpusCheckout,
+ options: LiveOptions,
+): Promise => {
+ const startedAt = performance.now();
+ const target = checkout.live;
+ if (!target) throw new Error(`${checkout.name} has no live target`);
+ const verification: LiveVerification = {
+ url: "",
+ entryFile: target.entryFile,
+ mountApi: null,
+ runtimeRootCount: 0,
+ commitCount: 0,
+ report: null,
+ diagnosticCount: 0,
+ pageErrors: [],
+ error: null,
+ durationMs: 0,
+ };
+ const log = options.onLog ?? (() => {});
+ try {
+ await installDependencies(checkout, target, log);
+ log("rendering static tree");
+ const { result, mountApi } = renderStaticRoot(checkout, target, options);
+ verification.mountApi = mountApi;
+ verification.diagnosticCount = result.diagnostics.length;
+ log(`static tree: ${result.root.fiberCount} fibers, ${result.root.unknownCount} unknown`);
+
+ log(`starting dev server: ${target.devCommand}`);
+ const server = await startDevServer(checkout, target);
+ verification.url = server.url;
+ try {
+ log(`capturing ${server.url}`);
+ const capture = await captureRuntimeTree(server.url, {
+ ...CAPTURE_TIMING,
+ captureScriptPath: options.captureScriptPath,
+ });
+ verification.runtimeRootCount = capture.roots.length;
+ verification.commitCount = capture.commitCount;
+ verification.pageErrors = capture.pageErrors;
+ const runtimeRoot = pickLargestRoot(capture.roots);
+ if (!runtimeRoot) throw new Error("React committed no roots");
+ verification.report = verifySnapshots(result.snapshot, runtimeRoot);
+ } finally {
+ await server.stop();
+ }
+ } catch (error) {
+ verification.error = getErrorMessage(error);
+ }
+ verification.durationMs = Math.round(performance.now() - startedAt);
+ return verification;
+};
diff --git a/packages/parser/src/corpus/process.ts b/packages/parser/src/corpus/process.ts
new file mode 100644
index 00000000..2f3b7cf8
--- /dev/null
+++ b/packages/parser/src/corpus/process.ts
@@ -0,0 +1,94 @@
+import { type ChildProcess, execFile, spawn } from "node:child_process";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+
+export interface RunOptions {
+ cwd: string;
+ env?: NodeJS.ProcessEnv;
+ timeoutMs?: number;
+}
+
+/** Runs a command to completion, returning stdout; failures include the command and stderr. */
+export const runCommand = async (
+ file: string,
+ args: string[],
+ options: RunOptions,
+): Promise => {
+ try {
+ const { stdout } = await execFileAsync(file, args, {
+ cwd: options.cwd,
+ env: options.env ?? process.env,
+ timeout: options.timeoutMs,
+ maxBuffer: 64 * 1024 * 1024,
+ });
+ return stdout;
+ } catch (error) {
+ const stderr = error instanceof Error && "stderr" in error ? String(error.stderr) : "";
+ throw new Error(`${file} ${args.join(" ")} failed in ${options.cwd}\n${stderr}`.trim());
+ }
+};
+
+/** Runs a shell command line (as written in corpus metadata) to completion. */
+export const runShell = (commandLine: string, options: RunOptions): Promise =>
+ runCommand("sh", ["-c", commandLine], options);
+
+export interface BackgroundProcess {
+ process: ChildProcess;
+ /** Combined stdout and stderr so far. */
+ getOutput: () => string;
+ /** Resolves when the process exits, with its code (or `null` when killed by a signal). */
+ exited: Promise;
+ /** Terminates the whole process group so shell-spawned children die with it. */
+ stop: () => Promise;
+}
+
+const KILL_GRACE_MS = 5_000;
+const MAX_OUTPUT_CHARS = 200_000;
+
+/**
+ * Starts a long-running shell command in its own process group, capturing
+ * output for diagnostics. Package-manager wrappers spawn the real server as
+ * a child, so stopping must signal the group rather than the leader.
+ */
+export const startBackgroundProcess = (
+ commandLine: string,
+ options: RunOptions,
+): BackgroundProcess => {
+ const child = spawn("sh", ["-c", commandLine], {
+ cwd: options.cwd,
+ env: options.env ?? process.env,
+ detached: true,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let output = "";
+ const append = (chunk: Buffer): void => {
+ output = (output + chunk.toString()).slice(-MAX_OUTPUT_CHARS);
+ };
+ child.stdout?.on("data", append);
+ child.stderr?.on("data", append);
+ const exited = new Promise((resolve) => {
+ child.on("exit", (code) => resolve(code));
+ child.on("error", () => resolve(null));
+ });
+ const signalGroup = (signal: NodeJS.Signals): void => {
+ if (child.pid === undefined || child.exitCode !== null) return;
+ try {
+ process.kill(-child.pid, signal);
+ } catch {
+ child.kill(signal);
+ }
+ };
+ return {
+ process: child,
+ getOutput: () => output,
+ exited,
+ stop: async () => {
+ if (child.exitCode !== null) return;
+ signalGroup("SIGTERM");
+ const timer = setTimeout(() => signalGroup("SIGKILL"), KILL_GRACE_MS);
+ await exited;
+ clearTimeout(timer);
+ },
+ };
+};
diff --git a/packages/parser/src/corpus/report.ts b/packages/parser/src/corpus/report.ts
new file mode 100644
index 00000000..7880bfa8
--- /dev/null
+++ b/packages/parser/src/corpus/report.ts
@@ -0,0 +1,178 @@
+import { mkdirSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { formatCoverage } from "../harness/verify.js";
+import { formatMismatches } from "../snapshot/match.js";
+import type { LiveVerification } from "./live.js";
+import type { ReasonCount, RepositoryScan } from "./scan.js";
+
+export interface CorpusEntryResult {
+ name: string;
+ scan: RepositoryScan | null;
+ live: LiveVerification | null;
+ /** Checkout or scan failure that prevented any result. */
+ error: string | null;
+}
+
+export interface CorpusReport {
+ generatedAt: string;
+ entries: CorpusEntryResult[];
+}
+
+const percent = (numerator: number, denominator: number): string =>
+ denominator === 0 ? "–" : `${((numerator / denominator) * 100).toFixed(1)}%`;
+
+const toFileName = (name: string): string => name.replace(/[^a-zA-Z0-9._-]+/g, "__");
+
+const formatReasons = (title: string, reasons: ReasonCount[], limit = 15): string[] => {
+ if (reasons.length === 0) return [];
+ return [
+ `### ${title}`,
+ "",
+ ...reasons.slice(0, limit).map((entry) => `- ${entry.count} × \`${entry.reason}\``),
+ "",
+ ];
+};
+
+const formatScan = (scan: RepositoryScan): string[] => {
+ const known = scan.components.fibers;
+ const total = known + scan.components.unknowns + scan.components.opaque;
+ return [
+ `- commit: ${scan.commit ?? "workspace"}; ${scan.framework}, react ${scan.reactVersion}`,
+ `- files: ${scan.files.parsed}/${scan.files.total} parsed, ${scan.files.withErrors} with syntax errors (client ${scan.files.byEnvironment.client}, server ${scan.files.byEnvironment.server}, shared ${scan.files.byEnvironment.shared})`,
+ `- imports: ${scan.imports.internal} internal, ${scan.imports.external} external, ${scan.imports.unresolved} unresolved of ${scan.imports.total}`,
+ `- components: ${scan.components.found} found, ${scan.components.rendered} rendered, ${scan.components.crashed} crashed, ${scan.components.timedOut} timed out, ${scan.components.fullyKnown} fully known (${percent(scan.components.fullyKnown, scan.components.rendered)})`,
+ `- nodes: ${known} fibers, ${scan.components.unknowns} unknown, ${scan.components.opaque} opaque → ${percent(known, total)} known`,
+ `- diagnostics: ${
+ Object.entries(scan.diagnostics)
+ .map(([code, total]) => `${code} ${total}`)
+ .join(", ") || "none"
+ }`,
+ `- scan time: ${(scan.durationMs / 1000).toFixed(1)}s`,
+ "",
+ ...formatReasons("Crashes", scan.crashes),
+ ...formatReasons("Unknown children", scan.unknownReasons),
+ ...formatReasons("Opaque packages", scan.opaquePackages),
+ ...formatReasons("Unresolved imports", scan.imports.unresolvedSpecifiers),
+ ...formatReasons("Diagnostics", scan.diagnosticMessages),
+ ];
+};
+
+const formatLive = (live: LiveVerification): string[] => {
+ const lines = [`- url: ${live.url || "not started"}; entry ${live.entryFile}`];
+ if (live.error) lines.push(`- error: ${live.error}`);
+ if (live.report) {
+ const report = live.report;
+ lines.push(
+ `- runtime: ${live.runtimeRootCount} root(s), ${live.commitCount} commit(s), ${report.runtimeFiberCount} fibers`,
+ `- static: ${report.staticFiberCount} fibers, ${report.staticUnknownCount} unknown, ${live.diagnosticCount} diagnostics`,
+ `- result: ${report.isMatch ? "match" : "MISMATCH"}; coverage ${formatCoverage(report.coverage)} (${report.explainedFiberCount}/${report.runtimeFiberCount} explained)`,
+ );
+ if (!report.isMatch) lines.push("", "```", formatMismatches(report.mismatches), "```");
+ }
+ if (live.pageErrors.length > 0) {
+ lines.push(`- page errors: ${live.pageErrors.length}`);
+ for (const pageError of live.pageErrors.slice(0, 5)) lines.push(` - ${pageError}`);
+ }
+ lines.push("");
+ return lines;
+};
+
+const formatSummaryTable = (entries: CorpusEntryResult[]): string[] => {
+ const header =
+ "| repository | files | components | crashed | timed out | fully known | known nodes | live match | live coverage |";
+ const divider = "| --- | ---: | ---: | ---: | ---: | ---: | ---: | :---: | ---: |";
+ const rows = entries.map((entry) => {
+ const scan = entry.scan;
+ const live = entry.live;
+ const known = scan ? scan.components.fibers : 0;
+ const total = scan ? known + scan.components.unknowns + scan.components.opaque : 0;
+ const liveMatch = live?.report
+ ? live.report.isMatch
+ ? "yes"
+ : "no"
+ : live?.error
+ ? "error"
+ : "–";
+ const liveCoverage = live?.report ? formatCoverage(live.report.coverage) : "–";
+ const scanCells = scan
+ ? [
+ scan.files.parsed,
+ scan.components.rendered,
+ scan.components.crashed,
+ scan.components.timedOut,
+ percent(scan.components.fullyKnown, scan.components.rendered),
+ percent(known, total),
+ ]
+ : ["–", "–", "–", "–", "–", "–"];
+ return `| ${[entry.name, ...scanCells, liveMatch, liveCoverage].join(" | ")} |`;
+ });
+ return [header, divider, ...rows];
+};
+
+const mergeReasons = (lists: ReasonCount[][]): ReasonCount[] => {
+ const totals = new Map();
+ for (const reasons of lists) {
+ for (const entry of reasons)
+ totals.set(entry.reason, (totals.get(entry.reason) ?? 0) + entry.count);
+ }
+ return [...totals]
+ .sort((left, right) => right[1] - left[1])
+ .map(([reason, total]) => ({ reason, count: total }));
+};
+
+export const formatCorpusReport = (report: CorpusReport): string => {
+ const scans = report.entries
+ .map((entry) => entry.scan)
+ .filter((scan): scan is RepositoryScan => scan !== null);
+ const lines = [
+ "# Corpus report",
+ "",
+ `Generated ${report.generatedAt} for ${report.entries.length} entries.`,
+ "",
+ ...formatSummaryTable(report.entries),
+ "",
+ ...formatReasons(
+ "Crashes across the corpus",
+ mergeReasons(scans.map((scan) => scan.crashes)),
+ 25,
+ ),
+ ...formatReasons(
+ "Unknown children across the corpus",
+ mergeReasons(scans.map((scan) => scan.unknownReasons)),
+ 25,
+ ),
+ ...formatReasons(
+ "Opaque packages across the corpus",
+ mergeReasons(scans.map((scan) => scan.opaquePackages)),
+ 25,
+ ),
+ ];
+ for (const entry of report.entries) {
+ lines.push(`## ${entry.name}`, "");
+ if (entry.error) lines.push(`- error: ${entry.error}`, "");
+ if (entry.scan) lines.push(...formatScan(entry.scan));
+ if (entry.live) lines.push("### Live", "", ...formatLive(entry.live));
+ }
+ return lines.join("\n");
+};
+
+/**
+ * Writes the machine-readable report, the markdown summary and, per entry,
+ * the rendered entry trees and live comparison trees for reading by hand.
+ */
+export const writeCorpusReport = (report: CorpusReport, outputDirectory: string): void => {
+ mkdirSync(outputDirectory, { recursive: true });
+ writeFileSync(join(outputDirectory, "report.json"), JSON.stringify(report, null, 2));
+ writeFileSync(join(outputDirectory, "report.md"), formatCorpusReport(report));
+ for (const entry of report.entries) {
+ const entryDirectory = join(outputDirectory, toFileName(entry.name));
+ mkdirSync(entryDirectory, { recursive: true });
+ for (const entryTree of entry.scan?.entries ?? []) {
+ writeFileSync(join(entryDirectory, `${toFileName(entryTree.filePath)}.txt`), entryTree.tree);
+ }
+ if (entry.live?.report) {
+ writeFileSync(join(entryDirectory, "live-static.txt"), entry.live.report.staticTree);
+ writeFileSync(join(entryDirectory, "live-runtime.txt"), entry.live.report.runtimeTree);
+ }
+ }
+};
diff --git a/packages/parser/src/corpus/repositories.ts b/packages/parser/src/corpus/repositories.ts
new file mode 100644
index 00000000..b1242dcf
--- /dev/null
+++ b/packages/parser/src/corpus/repositories.ts
@@ -0,0 +1,449 @@
+export type CorpusFramework =
+ | "vite"
+ | "rsbuild"
+ | "next"
+ | "remix"
+ | "react-router"
+ | "docusaurus"
+ | "cra";
+
+export type CorpusPackageManager = "pnpm" | "yarn" | "npm";
+
+/** How to boot an app's dev server and which page to compare against the static tree. */
+export interface LiveTarget {
+ /** Run from the root directory; installs dependencies. `null` when they are already installed. */
+ installCommand: string | null;
+ /** Run from the root directory; must serve `port` until killed. */
+ devCommand: string;
+ port: number;
+ /** Path to open, `/` by default. */
+ path?: string;
+ /**
+ * Module that mounts the app (`createRoot(el).render()`), relative
+ * to the root directory. Its mount call is the static root.
+ */
+ entryFile: string;
+ /** Milliseconds to allow the dev server's first compile. */
+ readyTimeoutMs: number;
+ /** Variables the app reads at boot, standing in for its `.env`. */
+ environment?: Record;
+}
+
+/** A corpus entry on disk, whether cloned from GitHub or living in this workspace. */
+export interface CorpusCheckout {
+ name: string;
+ rootDirectory: string;
+ /** Relative to `rootDirectory`. */
+ appDirectory: string;
+ /** Relative to `rootDirectory`. */
+ entryFiles: string[];
+ framework: CorpusFramework;
+ reactVersion: string;
+ live: LiveTarget | null;
+ /** Resolved commit for cloned repositories; `null` for workspace apps. */
+ commit: string | null;
+ /**
+ * Directories of linked workspace packages the resolver searches before
+ * `node_modules`; empty when the checkout's dependencies are installed.
+ */
+ moduleDirectories: string[];
+}
+
+export interface CorpusRepository {
+ /** `owner/name` on GitHub. */
+ slug: string;
+ defaultBranch: string;
+ framework: CorpusFramework;
+ packageManager: CorpusPackageManager;
+ reactVersion: string;
+ /** Directory whose component sources are scanned, relative to the repository root. */
+ appDirectory: string;
+ /** Route roots or mount points that make the best static entry points. */
+ entryFiles: string[];
+ /** `null` when the app needs a database or secrets before its first route renders. */
+ live: LiveTarget | null;
+ notes: string;
+}
+
+export const getRepositoryUrl = (repository: CorpusRepository): string =>
+ `https://github.com/${repository.slug}.git`;
+
+/** Filesystem-safe checkout directory name for a repository. */
+export const getRepositoryDirectoryName = (repository: CorpusRepository): string =>
+ repository.slug.replace("/", "__");
+
+/**
+ * Real-world React applications the analyzer is measured against. Metadata
+ * was gathered from each repository's manifests; `live` targets are apps
+ * whose first route renders without a backend.
+ */
+export const CORPUS_REPOSITORIES: CorpusRepository[] = [
+ {
+ slug: "calcom/cal.diy",
+ defaultBranch: "main",
+ framework: "next",
+ packageManager: "yarn",
+ reactVersion: "18.2.0",
+ appDirectory: "apps/web",
+ entryFiles: ["apps/web/app/layout.tsx", "apps/web/app/page.tsx"],
+ live: null,
+ notes: "Prisma + Postgres and NextAuth secrets are required before `/` renders.",
+ },
+ {
+ slug: "shadcn-ui/ui",
+ defaultBranch: "main",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "19.2.3",
+ appDirectory: "apps/v4",
+ entryFiles: ["apps/v4/app/layout.tsx", "apps/v4/app/(app)/(root)/page.tsx"],
+ live: null,
+ notes:
+ "Static docs and registry site on the App Router; server components dominate the routes.",
+ },
+ {
+ slug: "excalidraw/excalidraw",
+ defaultBranch: "master",
+ framework: "vite",
+ packageManager: "yarn",
+ reactVersion: "19.0.0",
+ appDirectory: "excalidraw-app",
+ entryFiles: ["excalidraw-app/index.tsx", "excalidraw-app/App.tsx"],
+ live: {
+ installCommand: "yarn install --immutable",
+ devCommand: "yarn --cwd excalidraw-app start --port 3005 --strictPort --host 127.0.0.1",
+ port: 3005,
+ entryFile: "excalidraw-app/index.tsx",
+ readyTimeoutMs: 180_000,
+ },
+ notes: "Yarn workspaces; the app package renders the whiteboard with no backend.",
+ },
+ {
+ slug: "tldraw/tldraw",
+ defaultBranch: "main",
+ framework: "vite",
+ packageManager: "yarn",
+ reactVersion: "^19.2.1",
+ appDirectory: "apps/examples",
+ entryFiles: ["apps/examples/src/index.tsx"],
+ live: {
+ installCommand: "yarn install --immutable",
+ devCommand:
+ "yarn workspace examples.tldraw.com dev --port 5420 --strictPort --host 127.0.0.1",
+ port: 5420,
+ entryFile: "apps/examples/src/index.tsx",
+ readyTimeoutMs: 180_000,
+ },
+ notes: "Examples gallery; the canvas itself uses a custom renderer on top of DOM.",
+ },
+ {
+ slug: "dubinc/dub",
+ defaultBranch: "main",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "19.1.3",
+ appDirectory: "apps/web",
+ entryFiles: ["apps/web/app/layout.tsx"],
+ live: null,
+ notes: "Needs Postgres, Redis and auth secrets.",
+ },
+ {
+ slug: "twentyhq/twenty",
+ defaultBranch: "main",
+ framework: "vite",
+ packageManager: "yarn",
+ reactVersion: "^19.2.0",
+ appDirectory: "packages/twenty-front",
+ entryFiles: ["packages/twenty-front/src/index.tsx"],
+ live: null,
+ notes: "GraphQL backend required at boot.",
+ },
+ {
+ slug: "formbricks/formbricks",
+ defaultBranch: "main",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "19.2.6",
+ appDirectory: "apps/web",
+ entryFiles: ["apps/web/app/layout.tsx"],
+ live: null,
+ notes: "Prisma + Postgres required.",
+ },
+ {
+ slug: "triggerdotdev/trigger.dev",
+ defaultBranch: "main",
+ framework: "remix",
+ packageManager: "pnpm",
+ reactVersion: "^18.2.0",
+ appDirectory: "apps/webapp",
+ entryFiles: ["apps/webapp/app/root.tsx"],
+ live: null,
+ notes: "Remix app backed by Postgres and Redis.",
+ },
+ {
+ slug: "novuhq/novu",
+ defaultBranch: "next",
+ framework: "vite",
+ packageManager: "pnpm",
+ reactVersion: "^19.2.3",
+ appDirectory: "apps/dashboard",
+ entryFiles: ["apps/dashboard/src/main.tsx"],
+ live: null,
+ notes: "Dashboard authenticates against the API before rendering.",
+ },
+ {
+ slug: "chakra-ui/chakra-ui",
+ defaultBranch: "main",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "19.2.6",
+ appDirectory: "apps/www",
+ entryFiles: ["apps/www/app/layout.tsx"],
+ live: null,
+ notes: "Docs site; the component library itself lives in packages/react.",
+ },
+ {
+ slug: "pierrecomputer/pierre",
+ defaultBranch: "main",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "19.2.7",
+ appDirectory: "apps/docs",
+ entryFiles: ["apps/docs/app/layout.tsx"],
+ live: null,
+ notes: "Small docs app; useful for a compact App Router sample.",
+ },
+ {
+ slug: "alan2207/bulletproof-react",
+ defaultBranch: "master",
+ framework: "vite",
+ packageManager: "yarn",
+ reactVersion: "^18.3.1",
+ appDirectory: "apps/react-vite",
+ entryFiles: ["apps/react-vite/src/main.tsx", "apps/react-vite/src/app/index.tsx"],
+ live: {
+ installCommand: "yarn --cwd apps/react-vite install --frozen-lockfile",
+ devCommand: "yarn --cwd apps/react-vite dev --port 3010 --strictPort --host 127.0.0.1",
+ port: 3010,
+ entryFile: "apps/react-vite/src/main.tsx",
+ readyTimeoutMs: 120_000,
+ environment: {
+ VITE_APP_API_URL: "https://api.bulletproofapp.com",
+ VITE_APP_ENABLE_API_MOCKING: "true",
+ },
+ },
+ notes: "Reference architecture app; API is mocked in the browser with MSW.",
+ },
+ {
+ slug: "lukevella/rallly",
+ defaultBranch: "main",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "19.2.6",
+ appDirectory: "apps/web",
+ entryFiles: ["apps/web/src/app/layout.tsx"],
+ live: null,
+ notes: "Prisma + Postgres required.",
+ },
+ {
+ slug: "umami-software/umami",
+ defaultBranch: "master",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "^19.2.8",
+ appDirectory: "src",
+ entryFiles: ["src/app/layout.tsx"],
+ live: null,
+ notes: "Database required at build time.",
+ },
+ {
+ slug: "mantinedev/mantine",
+ defaultBranch: "master",
+ framework: "next",
+ packageManager: "yarn",
+ reactVersion: "19.2.8",
+ appDirectory: "packages/@mantine/core/src",
+ entryFiles: ["packages/@mantine/core/src/index.ts"],
+ live: null,
+ notes: "Component library sources are the interesting part; the docs app is heavy to boot.",
+ },
+ {
+ slug: "marmelab/react-admin",
+ defaultBranch: "master",
+ framework: "vite",
+ packageManager: "yarn",
+ reactVersion: "^18.3.1",
+ appDirectory: "examples/simple",
+ entryFiles: ["examples/simple/src/index.tsx"],
+ live: {
+ installCommand: "yarn install --immutable",
+ devCommand: "yarn workspace simple dev --port 8080 --strictPort --host 127.0.0.1",
+ port: 8080,
+ entryFile: "examples/simple/src/index.tsx",
+ readyTimeoutMs: 180_000,
+ },
+ notes: "Framework-heavy admin UI with a fake REST data provider.",
+ },
+ {
+ slug: "TanStack/router",
+ defaultBranch: "main",
+ framework: "vite",
+ packageManager: "pnpm",
+ reactVersion: "^19.0.0",
+ appDirectory: "examples/react/basic",
+ entryFiles: ["examples/react/basic/src/main.tsx"],
+ live: {
+ installCommand: "pnpm install --frozen-lockfile",
+ devCommand:
+ "pnpm --filter tanstack-router-react-example-basic dev --port 3001 --strictPort --host 127.0.0.1",
+ port: 3001,
+ entryFile: "examples/react/basic/src/main.tsx",
+ readyTimeoutMs: 180_000,
+ },
+ notes: "Router example with code-based routes and a mock API.",
+ },
+ {
+ slug: "remix-run/react-router",
+ defaultBranch: "main",
+ framework: "react-router",
+ packageManager: "pnpm",
+ reactVersion: "^19.2.7",
+ appDirectory: "playground/framework",
+ entryFiles: ["playground/framework/app/root.tsx"],
+ live: null,
+ notes: "Framework-mode playground; hydration wraps the app in router internals.",
+ },
+ {
+ slug: "documenso/documenso",
+ defaultBranch: "main",
+ framework: "react-router",
+ packageManager: "npm",
+ reactVersion: "^19.2.7",
+ appDirectory: "apps/remix",
+ entryFiles: ["apps/remix/app/root.tsx"],
+ live: null,
+ notes: "Prisma + Postgres required.",
+ },
+ {
+ slug: "makeplane/plane",
+ defaultBranch: "preview",
+ framework: "react-router",
+ packageManager: "pnpm",
+ reactVersion: "19.2.8",
+ appDirectory: "apps/web",
+ entryFiles: ["apps/web/app/root.tsx"],
+ live: null,
+ notes: "Django API required.",
+ },
+ {
+ slug: "outline/outline",
+ defaultBranch: "main",
+ framework: "vite",
+ packageManager: "yarn",
+ reactVersion: "^18.3.1",
+ appDirectory: "app",
+ entryFiles: ["app/index.tsx"],
+ live: null,
+ notes: "Koa server with Postgres and Redis serves the SPA.",
+ },
+ {
+ slug: "vercel/ai-chatbot",
+ defaultBranch: "main",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "19.2.7",
+ appDirectory: ".",
+ entryFiles: ["app/layout.tsx"],
+ live: null,
+ notes:
+ "Auth and database required; small enough to read end to end. Components live beside `app/`.",
+ },
+ {
+ slug: "heroui-inc/heroui",
+ defaultBranch: "v3",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "19.2.6",
+ appDirectory: "packages/react/src/components",
+ entryFiles: ["packages/react/src/components/button/button.tsx"],
+ live: null,
+ notes: "Component library on react-aria-components; compound components built from slots.",
+ },
+ {
+ slug: "refinedev/refine",
+ defaultBranch: "main",
+ framework: "vite",
+ packageManager: "pnpm",
+ reactVersion: "^19.1.0",
+ appDirectory: "examples/base-antd",
+ entryFiles: ["examples/base-antd/src/index.tsx"],
+ live: null,
+ notes: "Ant Design example; the monorepo install is very large.",
+ },
+ {
+ slug: "pmndrs/react-three-fiber",
+ defaultBranch: "master",
+ framework: "vite",
+ packageManager: "yarn",
+ reactVersion: "19.2.0",
+ appDirectory: "example",
+ entryFiles: ["example/src/index.tsx"],
+ live: null,
+ notes: "The canvas subtree is reconciled by a custom renderer, not react-dom.",
+ },
+ {
+ slug: "facebook/docusaurus",
+ defaultBranch: "main",
+ framework: "docusaurus",
+ packageManager: "pnpm",
+ reactVersion: "^19.2.5",
+ appDirectory: "packages/docusaurus-theme-classic/src",
+ entryFiles: ["packages/docusaurus-theme-classic/src/theme/Layout/index.tsx"],
+ live: null,
+ notes: "Theme components are plain React; the framework injects them through swizzling.",
+ },
+ {
+ slug: "mui/material-ui",
+ defaultBranch: "master",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "19.2.8",
+ appDirectory: "packages/mui-material/src",
+ entryFiles: ["packages/mui-material/src/Button/Button.js"],
+ live: null,
+ notes: "Component library in JavaScript with styled() wrappers and PropTypes.",
+ },
+ {
+ slug: "payloadcms/payload",
+ defaultBranch: "main",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "19.2.6",
+ appDirectory: "packages/ui/src",
+ entryFiles: ["packages/ui/src/elements/Button/index.tsx"],
+ live: null,
+ notes: "Admin UI package; App Router app needs a database.",
+ },
+ {
+ slug: "supabase/supabase",
+ defaultBranch: "master",
+ framework: "next",
+ packageManager: "pnpm",
+ reactVersion: "^19.2.6",
+ appDirectory: "apps/studio/components",
+ entryFiles: ["apps/studio/pages/_app.tsx"],
+ live: null,
+ notes: "Studio uses the Pages Router and needs the platform API.",
+ },
+ {
+ slug: "appsmithorg/appsmith",
+ defaultBranch: "release",
+ framework: "cra",
+ packageManager: "yarn",
+ reactVersion: "^17.0.2",
+ appDirectory: "app/client/src",
+ entryFiles: ["app/client/src/index.tsx"],
+ live: null,
+ notes: "React 17 with class components and Redux; Java backend required.",
+ },
+];
diff --git a/packages/parser/src/corpus/scan.ts b/packages/parser/src/corpus/scan.ts
new file mode 100644
index 00000000..375c3029
--- /dev/null
+++ b/packages/parser/src/corpus/scan.ts
@@ -0,0 +1,430 @@
+import { join } from "node:path";
+import { AnalysisTimeoutError } from "../analyze/interpreter.js";
+import { callsHooksOrCreatesJsx, isComponentName } from "../analyze/naming.js";
+import {
+ describeValue,
+ getValueName,
+ object,
+ type ObjectValue,
+ type StaticValue,
+} from "../analyze/values.js";
+import type { StaticNode } from "../fiber/types.js";
+import { DEFAULT_EXPORT_NAME, type ModuleEnvironment, type ParsedModule } from "../module/types.js";
+import { createStaticRenderer, type StaticRenderer, type StaticRenderResult } from "../renderer.js";
+import { renderSnapshotTree } from "../snapshot/render.js";
+import type { CorpusCheckout, CorpusFramework } from "./repositories.js";
+import { listSourceFiles } from "./sources.js";
+
+export interface ScanOptions {
+ /** Link into `node_modules`; needs the checkout's dependencies installed. */
+ followExternalModules: boolean;
+ /** Fiber budget per component tree. */
+ maxFiberCount: number;
+ /** Wall-clock budget per component tree. */
+ timeBudgetMs: number;
+ /** Stop after this many components; `0` scans them all. */
+ maxComponents: number;
+ onProgress?: (message: string) => void;
+}
+
+export type ComponentKind = "function" | "class" | "memo" | "forwardRef" | "lazy";
+
+export type ComponentScanStatus = "rendered" | "crashed" | "timed-out";
+
+export interface ComponentScan {
+ /** Relative to the checkout root. */
+ filePath: string;
+ exportName: string;
+ name: string | null;
+ /** `null` when evaluating the export crashed before it could be classified. */
+ kind: ComponentKind | null;
+ status: ComponentScanStatus;
+ fiberCount: number;
+ unknownCount: number;
+ /** Fibers whose implementation is outside the analyzed graph. */
+ opaqueCount: number;
+ hookCount: number;
+ durationMs: number;
+ /** Message of the analyzer exception, unless `rendered`. */
+ error: string | null;
+}
+
+export interface ReasonCount {
+ reason: string;
+ count: number;
+}
+
+export interface ImportStats {
+ total: number;
+ /** Resolved to a project module. */
+ internal: number;
+ /** Resolved into `node_modules` or a Node builtin. */
+ external: number;
+ unresolved: number;
+ /** Specifiers that resolved to nothing, most frequent first. */
+ unresolvedSpecifiers: ReasonCount[];
+}
+
+export interface EntryTree {
+ filePath: string;
+ /** `mount` for `createRoot().render(...)`, `export` for the default export. */
+ source: "mount" | "export";
+ fiberCount: number;
+ unknownCount: number;
+ tree: string;
+}
+
+export interface RepositoryScan {
+ name: string;
+ commit: string | null;
+ framework: CorpusFramework;
+ reactVersion: string;
+ appDirectory: string;
+ files: {
+ total: number;
+ parsed: number;
+ /** Modules Oxc reported at least one syntax error for. */
+ withErrors: number;
+ byEnvironment: Record;
+ };
+ imports: ImportStats;
+ components: {
+ found: number;
+ rendered: number;
+ crashed: number;
+ timedOut: number;
+ /** Rendered without a single unknown or opaque node. */
+ fullyKnown: number;
+ fibers: number;
+ unknowns: number;
+ opaque: number;
+ };
+ tags: Record;
+ diagnostics: Record;
+ /** Most common diagnostic messages, e.g. the free identifiers nothing declares. */
+ diagnosticMessages: ReasonCount[];
+ /** Most common descriptions of children the analysis gave up on. */
+ unknownReasons: ReasonCount[];
+ /** Packages whose components appear as opaque fibers most often. */
+ opaquePackages: ReasonCount[];
+ /** Analyzer exceptions grouped by message; each one is a bug. */
+ crashes: ReasonCount[];
+ entries: EntryTree[];
+ componentList: ComponentScan[];
+ durationMs: number;
+}
+
+export const DEFAULT_SCAN_OPTIONS: ScanOptions = {
+ followExternalModules: false,
+ maxFiberCount: 5_000,
+ timeBudgetMs: 10_000,
+ maxComponents: 0,
+};
+
+const TOP_REASONS = 40;
+const MAX_REASON_LENGTH = 120;
+
+interface Histogram {
+ counts: Map;
+}
+
+const createHistogram = (): Histogram => ({ counts: new Map() });
+
+const count = (histogram: Histogram, key: string): void => {
+ histogram.counts.set(key, (histogram.counts.get(key) ?? 0) + 1);
+};
+
+const topReasons = (histogram: Histogram, limit = TOP_REASONS): ReasonCount[] =>
+ [...histogram.counts]
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
+ .slice(0, limit)
+ .map(([reason, total]) => ({ reason, count: total }));
+
+const toRecord = (histogram: Histogram): Record =>
+ Object.fromEntries([...histogram.counts].sort((left, right) => right[1] - left[1]));
+
+const truncate = (value: string): string => {
+ const collapsed = value.replace(/\s+/g, " ").trim();
+ return collapsed.length > MAX_REASON_LENGTH
+ ? `${collapsed.slice(0, MAX_REASON_LENGTH - 1)}…`
+ : collapsed;
+};
+
+interface TreeStats {
+ fibers: number;
+ unknowns: number;
+ opaque: number;
+}
+
+interface Histograms {
+ tags: Histogram;
+ unknownReasons: Histogram;
+ opaquePackages: Histogram;
+}
+
+const describeOpaqueSource = (type: StaticValue | null): string => {
+ if (type?.kind === "external") return type.packageName ?? type.specifier;
+ return type ? truncate(describeValue(type)) : "unknown type";
+};
+
+const collectTreeStats = (nodes: StaticNode[], stats: TreeStats, histograms: Histograms): void => {
+ for (const node of nodes) {
+ switch (node.kind) {
+ case "fiber":
+ stats.fibers++;
+ if (node.tag === null) {
+ stats.opaque++;
+ count(histograms.tags, "Opaque");
+ count(histograms.opaquePackages, describeOpaqueSource(node.type));
+ } else {
+ count(histograms.tags, node.tag);
+ }
+ collectTreeStats(node.children, stats, histograms);
+ if (node.fallback) collectTreeStats(node.fallback, stats, histograms);
+ break;
+ case "branch":
+ for (const alternative of node.alternatives)
+ collectTreeStats(alternative, stats, histograms);
+ break;
+ case "list":
+ collectTreeStats(node.items, stats, histograms);
+ break;
+ case "unknown":
+ stats.unknowns++;
+ count(histograms.unknownReasons, truncate(node.description));
+ break;
+ }
+ }
+};
+
+const getComponentKind = (value: StaticValue, exportName: string): ComponentKind | null => {
+ if (value.kind === "function") {
+ const name = value.name ?? exportName;
+ return isComponentName(name) && callsHooksOrCreatesJsx(value.fn) ? "function" : null;
+ }
+ if (value.kind !== "component") return null;
+ switch (value.definition.kind) {
+ case "class":
+ case "memo":
+ case "forwardRef":
+ case "lazy":
+ return value.definition.kind;
+ default:
+ return null;
+ }
+};
+
+const getErrorMessage = (error: unknown): string =>
+ error instanceof Error ? `${error.name}: ${error.message}` : String(error);
+
+const countImports = (
+ renderer: StaticRenderer,
+ module: ParsedModule,
+ imports: ImportStats,
+ unresolvedSpecifiers: Histogram,
+): void => {
+ const requests = new Set();
+ for (const binding of module.bindings.values()) {
+ if (binding.kind === "import" && !binding.isTypeOnly) requests.add(binding.moduleRequest);
+ }
+ for (const named of module.exports.named.values()) {
+ if (named.kind === "reexport") requests.add(named.moduleRequest);
+ }
+ for (const star of module.exports.stars) requests.add(star.moduleRequest);
+ for (const request of requests) {
+ imports.total++;
+ const resolved = renderer.project.resolveSpecifier(module.filePath, request);
+ if (!resolved) {
+ imports.unresolved++;
+ count(unresolvedSpecifiers, request);
+ } else if (resolved.isExternal) imports.external++;
+ else imports.internal++;
+ }
+};
+
+/** Component props are unknown at scan time: every read yields an unknown value. */
+const UNKNOWN_PROPS: ObjectValue = object([], true);
+
+/**
+ * Evaluates one export and, when it is a component, renders it. Returns
+ * `null` for exports that are not components; a crash while evaluating the
+ * export is still reported, since it may hide a component.
+ */
+const scanExport = (
+ renderer: StaticRenderer,
+ module: ParsedModule,
+ exportName: string,
+ relativePath: string,
+ histograms: Histograms,
+): ComponentScan | null => {
+ const startedAt = performance.now();
+ const scan: ComponentScan = {
+ filePath: relativePath,
+ exportName,
+ name: exportName === DEFAULT_EXPORT_NAME ? null : exportName,
+ kind: null,
+ status: "rendered",
+ fiberCount: 0,
+ unknownCount: 0,
+ opaqueCount: 0,
+ hookCount: 0,
+ durationMs: 0,
+ error: null,
+ };
+ try {
+ const value = renderer.getExportValue(module.filePath, exportName);
+ scan.kind = getComponentKind(value, exportName);
+ if (scan.kind === null) return null;
+ scan.name = getValueName(value) ?? scan.name;
+ const result = renderer.renderExport(module.filePath, exportName, UNKNOWN_PROPS);
+ const stats: TreeStats = { fibers: 0, unknowns: 0, opaque: 0 };
+ collectTreeStats(result.root.root.children, stats, histograms);
+ scan.fiberCount = stats.fibers;
+ scan.unknownCount = stats.unknowns;
+ scan.opaqueCount = stats.opaque;
+ const [componentFiber] = result.root.root.children;
+ scan.hookCount = componentFiber?.kind === "fiber" ? componentFiber.hooks.length : 0;
+ } catch (error) {
+ scan.status = error instanceof AnalysisTimeoutError ? "timed-out" : "crashed";
+ scan.error = getErrorMessage(error);
+ }
+ scan.durationMs = Math.round(performance.now() - startedAt);
+ return scan;
+};
+
+const renderEntry = (
+ renderer: StaticRenderer,
+ checkout: CorpusCheckout,
+ entryFile: string,
+): EntryTree | null => {
+ const filePath = join(checkout.rootDirectory, entryFile);
+ if (!renderer.project.getModule(filePath)) return null;
+ const collect = (source: EntryTree["source"], result: StaticRenderResult): EntryTree => ({
+ filePath: entryFile,
+ source,
+ fiberCount: result.root.fiberCount,
+ unknownCount: result.root.unknownCount,
+ tree: renderSnapshotTree(result.snapshot),
+ });
+ try {
+ const [mount] = renderer.findMountPoints(filePath);
+ if (mount) return collect("mount", renderer.renderValue(mount.element));
+ const value = renderer.getExportValue(filePath);
+ return getComponentKind(value, DEFAULT_EXPORT_NAME)
+ ? collect("export", renderer.renderExport(filePath, DEFAULT_EXPORT_NAME, UNKNOWN_PROPS))
+ : null;
+ } catch (error) {
+ return {
+ filePath: entryFile,
+ source: "export",
+ fiberCount: 0,
+ unknownCount: 0,
+ tree: `crashed: ${getErrorMessage(error)}`,
+ };
+ }
+};
+
+/**
+ * Parses every source module under the checkout's app directory, renders
+ * each exported component with unknown props and aggregates what the
+ * analysis could and could not explain. Exceptions are recorded rather than
+ * thrown: a crash on real code is a finding.
+ */
+export const scanCheckout = (
+ checkout: CorpusCheckout,
+ options: ScanOptions = DEFAULT_SCAN_OPTIONS,
+): RepositoryScan => {
+ const startedAt = performance.now();
+ const renderer = createStaticRenderer({
+ rootDirectory: checkout.rootDirectory,
+ moduleDirectories: checkout.moduleDirectories,
+ followExternalModules: options.followExternalModules,
+ build: { maxFiberCount: options.maxFiberCount },
+ timeBudgetMs: options.timeBudgetMs,
+ });
+ const files = listSourceFiles(checkout.rootDirectory, checkout.appDirectory);
+ const histograms: Histograms = {
+ tags: createHistogram(),
+ unknownReasons: createHistogram(),
+ opaquePackages: createHistogram(),
+ };
+ const crashes = createHistogram();
+ const unresolvedSpecifiers = createHistogram();
+ const byEnvironment: Record = { client: 0, server: 0, shared: 0 };
+ const imports: ImportStats = {
+ total: 0,
+ internal: 0,
+ external: 0,
+ unresolved: 0,
+ unresolvedSpecifiers: [],
+ };
+ const componentList: ComponentScan[] = [];
+ let parsed = 0;
+ let withErrors = 0;
+ let found = 0;
+
+ for (const [index, relativePath] of files.entries()) {
+ if (options.maxComponents > 0 && componentList.length >= options.maxComponents) break;
+ const module = renderer.project.getModule(join(checkout.rootDirectory, relativePath));
+ if (!module) continue;
+ parsed++;
+ if (module.errors.length > 0) withErrors++;
+ byEnvironment[module.environment]++;
+ countImports(renderer, module, imports, unresolvedSpecifiers);
+ if (index % 100 === 0) {
+ options.onProgress?.(`${index}/${files.length} files, ${componentList.length} components`);
+ }
+ for (const exportName of module.exports.named.keys()) {
+ const scan = scanExport(renderer, module, exportName, relativePath, histograms);
+ if (!scan) continue;
+ if (scan.kind !== null) found++;
+ if (scan.status === "crashed" && scan.error) count(crashes, truncate(scan.error));
+ componentList.push(scan);
+ }
+ }
+
+ const entries = checkout.entryFiles
+ .map((entryFile) => renderEntry(renderer, checkout, entryFile))
+ .filter((entry): entry is EntryTree => entry !== null);
+
+ const diagnostics = createHistogram();
+ const diagnosticMessages = createHistogram();
+ for (const diagnostic of renderer.interpreter.diagnostics) {
+ count(diagnostics, diagnostic.code);
+ count(diagnosticMessages, `${diagnostic.code}: ${truncate(diagnostic.message)}`);
+ }
+ imports.unresolvedSpecifiers = topReasons(unresolvedSpecifiers);
+
+ const rendered = componentList.filter((scan) => scan.status === "rendered");
+ const countStatus = (status: ComponentScanStatus): number =>
+ componentList.filter((scan) => scan.status === status).length;
+ return {
+ name: checkout.name,
+ commit: checkout.commit,
+ framework: checkout.framework,
+ reactVersion: checkout.reactVersion,
+ appDirectory: checkout.appDirectory,
+ files: { total: files.length, parsed, withErrors, byEnvironment },
+ imports,
+ components: {
+ found,
+ rendered: rendered.length,
+ crashed: countStatus("crashed"),
+ timedOut: countStatus("timed-out"),
+ fullyKnown: rendered.filter((scan) => scan.unknownCount === 0 && scan.opaqueCount === 0)
+ .length,
+ fibers: rendered.reduce((total, scan) => total + scan.fiberCount, 0),
+ unknowns: rendered.reduce((total, scan) => total + scan.unknownCount, 0),
+ opaque: rendered.reduce((total, scan) => total + scan.opaqueCount, 0),
+ },
+ tags: toRecord(histograms.tags),
+ diagnostics: toRecord(diagnostics),
+ diagnosticMessages: topReasons(diagnosticMessages),
+ unknownReasons: topReasons(histograms.unknownReasons),
+ opaquePackages: topReasons(histograms.opaquePackages),
+ crashes: topReasons(crashes),
+ entries,
+ componentList: componentList.sort((left, right) => right.fiberCount - left.fiberCount),
+ durationMs: Math.round(performance.now() - startedAt),
+ };
+};
diff --git a/packages/parser/src/corpus/sources.ts b/packages/parser/src/corpus/sources.ts
new file mode 100644
index 00000000..ca9da9c3
--- /dev/null
+++ b/packages/parser/src/corpus/sources.ts
@@ -0,0 +1,54 @@
+import { type Dirent, readdirSync } from "node:fs";
+import { join, relative } from "node:path";
+import { isSourceFilePath } from "../module/parse.js";
+
+const SKIPPED_DIRECTORIES = new Set([
+ "node_modules",
+ ".git",
+ ".next",
+ ".turbo",
+ ".cache",
+ ".vercel",
+ "dist",
+ "build",
+ "out",
+ "coverage",
+ "storybook-static",
+ "__tests__",
+ "__mocks__",
+ "__snapshots__",
+ "__fixtures__",
+]);
+
+/** Generated, test and story modules are not the product's component tree. */
+const SKIPPED_FILE_PATTERN = /\.(test|spec|stories|story|d)\.[cm]?[jt]sx?$|\.d\.ts$/;
+
+/**
+ * Source modules under `directory`, sorted, as paths relative to
+ * `rootDirectory`. Test and build output directories are skipped.
+ */
+export const listSourceFiles = (rootDirectory: string, directory: string): string[] => {
+ const files: string[] = [];
+ const visit = (currentDirectory: string): void => {
+ let entries: Dirent[];
+ try {
+ entries = readdirSync(currentDirectory, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const entryPath = join(currentDirectory, entry.name);
+ if (entry.isDirectory()) {
+ if (!SKIPPED_DIRECTORIES.has(entry.name)) visit(entryPath);
+ } else if (
+ entry.isFile() &&
+ isSourceFilePath(entry.name) &&
+ !SKIPPED_FILE_PATTERN.test(entry.name)
+ ) {
+ files.push(relative(rootDirectory, entryPath));
+ }
+ }
+ };
+ visit(join(rootDirectory, directory));
+ return files.sort();
+};
diff --git a/packages/parser/src/corpus/workspace-apps.ts b/packages/parser/src/corpus/workspace-apps.ts
new file mode 100644
index 00000000..6c76ba68
--- /dev/null
+++ b/packages/parser/src/corpus/workspace-apps.ts
@@ -0,0 +1,104 @@
+import type { CorpusCheckout, CorpusFramework, LiveTarget } from "./repositories.js";
+
+/**
+ * A fixture app inside this monorepo. Its dependencies are installed with
+ * the workspace, which makes it the fastest way to exercise the live
+ * pipeline end to end; paths are relative to the monorepo root.
+ */
+export interface WorkspaceApp {
+ /** Workspace package name. */
+ name: string;
+ framework: CorpusFramework;
+ reactVersion: string;
+ appDirectory: string;
+ entryFiles: string[];
+ live: LiveTarget;
+}
+
+const devCommand = (packageName: string, port: number): string =>
+ `pnpm --filter ${packageName} dev --port ${port} --strictPort --host 127.0.0.1`;
+
+export const WORKSPACE_APPS: WorkspaceApp[] = [
+ {
+ name: "@bippy/e2e-vite",
+ framework: "vite",
+ reactVersion: "^19.0.0",
+ appDirectory: "packages/e2e/fixtures/vite-app/src",
+ entryFiles: ["packages/e2e/fixtures/vite-app/src/main.tsx"],
+ live: {
+ installCommand: null,
+ devCommand: devCommand("@bippy/e2e-vite", 5280),
+ port: 5280,
+ entryFile: "packages/e2e/fixtures/vite-app/src/main.tsx",
+ readyTimeoutMs: 60_000,
+ },
+ },
+ {
+ name: "@bippy/e2e-kitchen-sink",
+ framework: "vite",
+ reactVersion: "^19.0.0",
+ appDirectory: "packages/e2e/fixtures/kitchen-sink-app/src",
+ entryFiles: ["packages/e2e/fixtures/kitchen-sink-app/src/main.tsx"],
+ live: {
+ installCommand: null,
+ devCommand: devCommand("@bippy/e2e-kitchen-sink", 5299),
+ port: 5299,
+ entryFile: "packages/e2e/fixtures/kitchen-sink-app/src/main.tsx",
+ readyTimeoutMs: 120_000,
+ },
+ },
+ {
+ name: "@bippy/e2e-rsbuild",
+ framework: "rsbuild",
+ reactVersion: "^19.0.0",
+ appDirectory: "packages/e2e/fixtures/rsbuild-app/src",
+ entryFiles: ["packages/e2e/fixtures/rsbuild-app/src/index.tsx"],
+ live: {
+ installCommand: null,
+ devCommand: "pnpm --filter @bippy/e2e-rsbuild dev --port 5500 --host 127.0.0.1",
+ port: 5500,
+ entryFile: "packages/e2e/fixtures/rsbuild-app/src/index.tsx",
+ readyTimeoutMs: 60_000,
+ },
+ },
+ {
+ name: "@bippy/e2e-tanstack",
+ framework: "vite",
+ reactVersion: "^19.0.0",
+ appDirectory: "packages/e2e/fixtures/tanstack-app/src",
+ entryFiles: ["packages/e2e/fixtures/tanstack-app/src/client.tsx"],
+ live: {
+ installCommand: null,
+ devCommand: devCommand("@bippy/e2e-tanstack", 5300),
+ port: 5300,
+ entryFile: "packages/e2e/fixtures/tanstack-app/src/client.tsx",
+ readyTimeoutMs: 120_000,
+ },
+ },
+ {
+ name: "@bippy/e2e-react-router",
+ framework: "react-router",
+ reactVersion: "^19.0.0",
+ appDirectory: "packages/e2e/fixtures/react-router-app/app",
+ entryFiles: ["packages/e2e/fixtures/react-router-app/app/entry.client.tsx"],
+ live: {
+ installCommand: null,
+ devCommand: devCommand("@bippy/e2e-react-router", 5400),
+ port: 5400,
+ entryFile: "packages/e2e/fixtures/react-router-app/app/entry.client.tsx",
+ readyTimeoutMs: 120_000,
+ },
+ },
+];
+
+export const toWorkspaceCheckout = (app: WorkspaceApp, monorepoRoot: string): CorpusCheckout => ({
+ name: app.name,
+ rootDirectory: monorepoRoot,
+ appDirectory: app.appDirectory,
+ entryFiles: app.entryFiles,
+ framework: app.framework,
+ reactVersion: app.reactVersion,
+ live: app.live,
+ commit: null,
+ moduleDirectories: [],
+});
diff --git a/packages/parser/src/corpus/workspaces.ts b/packages/parser/src/corpus/workspaces.ts
new file mode 100644
index 00000000..7a86a238
--- /dev/null
+++ b/packages/parser/src/corpus/workspaces.ts
@@ -0,0 +1,149 @@
+import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+export interface WorkspacePackage {
+ name: string;
+ /** Absolute package directory. */
+ directory: string;
+}
+
+const isRecord = (value: unknown): value is Record =>
+ typeof value === "object" && value !== null && !Array.isArray(value);
+
+const readJson = (filePath: string): unknown => {
+ try {
+ return JSON.parse(readFileSync(filePath, "utf8"));
+ } catch {
+ return undefined;
+ }
+};
+
+const toStringList = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
+
+/**
+ * The `packages` list of `pnpm-workspace.yaml`. Workspace files only ever
+ * use a flat list of quoted or bare strings under that key, so a line reader
+ * covers them without a YAML dependency.
+ */
+const readPnpmWorkspaceGlobs = (rootDirectory: string): string[] => {
+ const filePath = join(rootDirectory, "pnpm-workspace.yaml");
+ if (!existsSync(filePath)) return [];
+ const globs: string[] = [];
+ let isInPackages = false;
+ for (const line of readFileSync(filePath, "utf8").split("\n")) {
+ if (/^packages\s*:/.test(line)) {
+ isInPackages = true;
+ continue;
+ }
+ if (isInPackages) {
+ const item = /^\s+-\s*(.+?)\s*$/.exec(line);
+ if (item) globs.push(item[1].replace(/^["']|["']$/g, ""));
+ else if (/^\S/.test(line)) isInPackages = false;
+ }
+ }
+ return globs;
+};
+
+/** `workspaces` of the root package.json, in its array or `{ packages }` form. */
+const readPackageWorkspaceGlobs = (rootDirectory: string): string[] => {
+ const manifest = readJson(join(rootDirectory, "package.json"));
+ if (!isRecord(manifest)) return [];
+ const { workspaces } = manifest;
+ return toStringList(isRecord(workspaces) ? workspaces.packages : workspaces);
+};
+
+const listSubdirectories = (directory: string): string[] => {
+ try {
+ return readdirSync(directory, { withFileTypes: true })
+ .filter(
+ (entry) =>
+ entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules",
+ )
+ .map((entry) => join(directory, entry.name));
+ } catch {
+ return [];
+ }
+};
+
+const listSubdirectoriesDeep = (directory: string): string[] =>
+ listSubdirectories(directory).flatMap((child) => [child, ...listSubdirectoriesDeep(child)]);
+
+const toSegmentPattern = (segment: string): RegExp =>
+ new RegExp(`^${segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*")}$`);
+
+/** Directories matching a workspace glob such as `packages/*`, `apps/**` or `packages/twenty-*`. */
+const expandWorkspaceGlob = (rootDirectory: string, glob: string): string[] => {
+ let directories = [rootDirectory];
+ for (const segment of glob.replace(/^\.\//, "").split("/").filter(Boolean)) {
+ if (segment === "**") {
+ directories = directories.flatMap((directory) => [
+ directory,
+ ...listSubdirectoriesDeep(directory),
+ ]);
+ } else if (segment.includes("*")) {
+ const pattern = toSegmentPattern(segment);
+ directories = directories.flatMap((directory) =>
+ listSubdirectories(directory).filter((child) =>
+ pattern.test(child.slice(directory.length + 1)),
+ ),
+ );
+ } else {
+ directories = directories.map((directory) => join(directory, segment));
+ }
+ }
+ return [...new Set(directories)].filter((directory) =>
+ existsSync(join(directory, "package.json")),
+ );
+};
+
+/**
+ * Packages declared by the root's pnpm, yarn or npm workspace configuration.
+ * The first package to claim a name wins, as it does for package managers.
+ */
+export const findWorkspacePackages = (rootDirectory: string): WorkspacePackage[] => {
+ const globs = [
+ ...readPnpmWorkspaceGlobs(rootDirectory),
+ ...readPackageWorkspaceGlobs(rootDirectory),
+ ];
+ const excluded = new Set(
+ globs
+ .filter((glob) => glob.startsWith("!"))
+ .flatMap((glob) => expandWorkspaceGlob(rootDirectory, glob.slice(1))),
+ );
+ const packages = new Map();
+ for (const glob of globs) {
+ if (glob.startsWith("!")) continue;
+ for (const directory of expandWorkspaceGlob(rootDirectory, glob)) {
+ if (excluded.has(directory)) continue;
+ const manifest = readJson(join(directory, "package.json"));
+ if (!isRecord(manifest) || typeof manifest.name !== "string") continue;
+ if (!packages.has(manifest.name))
+ packages.set(manifest.name, { name: manifest.name, directory });
+ }
+ }
+ return [...packages.values()];
+};
+
+/**
+ * Symlinks every workspace package under `/node_modules`,
+ * which is what installing the workspace would have created inside the
+ * checkout. Kept outside the checkout so it never collides with a real
+ * install. Returns the `node_modules` directory, or `null` when the root
+ * declares no workspace.
+ */
+export const linkWorkspacePackages = (
+ rootDirectory: string,
+ linksDirectory: string,
+): string | null => {
+ const packages = findWorkspacePackages(rootDirectory);
+ if (packages.length === 0) return null;
+ const modulesDirectory = join(linksDirectory, "node_modules");
+ for (const workspacePackage of packages) {
+ const linkPath = join(modulesDirectory, workspacePackage.name);
+ mkdirSync(dirname(linkPath), { recursive: true });
+ rmSync(linkPath, { force: true });
+ symlinkSync(workspacePackage.directory, linkPath, "dir");
+ }
+ return modulesDirectory;
+};
diff --git a/packages/parser/src/fiber/build.ts b/packages/parser/src/fiber/build.ts
new file mode 100644
index 00000000..92d0bf33
--- /dev/null
+++ b/packages/parser/src/fiber/build.ts
@@ -0,0 +1,669 @@
+import { instantiateClassComponent, resolveClassProps } from "../analyze/components.js";
+import {
+ EMPTY_CONTEXTS,
+ provideContext,
+ type ProvidedContexts,
+ readContext,
+} from "../analyze/contexts.js";
+import type { EvaluationContext, Interpreter } from "../analyze/interpreter.js";
+import {
+ type BuiltinComponentName,
+ cloneObject,
+ component,
+ type ComponentDefinition,
+ describeValue,
+ type ElementValue,
+ type FunctionValue,
+ getComponentName,
+ getObjectProperty,
+ getValueName,
+ isNullish,
+ type ListValue,
+ NULL,
+ object,
+ type ObjectValue,
+ type StaticValue,
+ unknown,
+} from "../analyze/values.js";
+import { getHostWorkTag, isDirectTextChild, shouldSetTextContent } from "./host.js";
+import type {
+ BranchNode,
+ ListNode,
+ StaticFiber,
+ StaticNode,
+ StaticRoot,
+ UnknownNode,
+ WorkTagName,
+} from "./types.js";
+
+export interface BuildOptions {
+ /** Component render nesting after which subtrees become unknown. */
+ maxRenderDepth?: number;
+ /** Times one component may appear in its own ancestry before the recursion is cut. */
+ maxRecursion?: number;
+ /** Total fiber budget for one tree. */
+ maxFiberCount?: number;
+}
+
+interface Frame {
+ contexts: ProvidedContexts;
+ depth: number;
+ isInsideSvg: boolean;
+ /** Component implementations currently rendering, outermost first. */
+ renderStack: readonly object[];
+}
+
+interface Builder {
+ interpreter: Interpreter;
+ options: Required;
+ fiberCount: number;
+ unknownCount: number;
+}
+
+interface FiberInit {
+ tag: WorkTagName | null;
+ name: string | null;
+ element: ElementValue | null;
+ owner: StaticFiber | null;
+}
+
+const ROOT_FRAME: Frame = {
+ contexts: EMPTY_CONTEXTS,
+ depth: 0,
+ isInsideSvg: false,
+ renderStack: [],
+};
+
+const BUILTIN_FIBER_NAMES: Record = {
+ Fragment: "Fragment",
+ Suspense: "Suspense",
+ SuspenseList: "SuspenseList",
+ StrictMode: "StrictMode",
+ Profiler: "Profiler",
+ Activity: "Activity",
+ ViewTransition: "ViewTransition",
+ Portal: "Portal",
+};
+
+const childrenOf = (props: ObjectValue): StaticValue => getObjectProperty(props, "children");
+
+const isUnkeyedFragment = (value: StaticValue): value is ElementValue =>
+ value.kind === "element" &&
+ value.key === null &&
+ value.type.kind === "component" &&
+ value.type.definition.kind === "builtin" &&
+ value.type.definition.name === "Fragment";
+
+/** React coerces keys with `"" + key`; keys only known at runtime stay `null`. */
+const getKey = (key: StaticValue | null): string | null => {
+ if (key === null || key.kind !== "literal" || isNullish(key.value)) return null;
+ return String(key.value);
+};
+
+const unknownNode = (builder: Builder, description: string): UnknownNode => {
+ builder.unknownCount++;
+ return { kind: "unknown", description };
+};
+
+const branch = (test: string, alternatives: StaticNode[][]): StaticNode[] => {
+ if (alternatives.every((alternative) => alternative.length === 0)) return [];
+ const node: BranchNode = { kind: "branch", test, alternatives };
+ return [node];
+};
+
+const createFiber = (
+ builder: Builder,
+ parent: StaticFiber | null,
+ init: FiberInit,
+): StaticFiber => {
+ builder.fiberCount++;
+ const element = init.element;
+ return {
+ kind: "fiber",
+ tag: init.tag,
+ name: init.name,
+ key: element ? getKey(element.key) : null,
+ text: null,
+ props: element?.props ?? null,
+ type: element?.type ?? null,
+ location: element?.location ?? null,
+ owner: init.owner,
+ parent,
+ children: [],
+ hooks: [],
+ annotations: [],
+ fallback: null,
+ };
+};
+
+const createTextFiber = (
+ builder: Builder,
+ parent: StaticFiber,
+ text: string | null,
+): StaticFiber => ({
+ ...createFiber(builder, parent, { tag: "HostText", name: null, element: null, owner: parent }),
+ text,
+});
+
+const isOverBudget = (builder: Builder): boolean =>
+ builder.fiberCount >= builder.options.maxFiberCount;
+
+/**
+ * `reconcileChildFibers`: the value a render returned (or a `children`
+ * prop) becomes a sequence of sibling fibers. A top-level array is a set of
+ * children and a top-level unkeyed fragment is unwrapped once.
+ */
+const reconcileChildren = (
+ builder: Builder,
+ parent: StaticFiber,
+ value: StaticValue,
+ frame: Frame,
+ canUnwrapFragment = true,
+): StaticNode[] => {
+ if (isOverBudget(builder)) return [unknownNode(builder, "fiber budget exceeded")];
+ switch (value.kind) {
+ case "conditional":
+ return branch(value.test, [
+ reconcileChildren(builder, parent, value.whenTrue, frame, canUnwrapFragment),
+ reconcileChildren(builder, parent, value.whenFalse, frame, canUnwrapFragment),
+ ]);
+ case "optional":
+ return branch(value.test, [
+ reconcileChildren(builder, parent, value.value, frame, canUnwrapFragment),
+ [],
+ ]);
+ case "element":
+ if (canUnwrapFragment && isUnkeyedFragment(value)) {
+ return reconcileChildren(builder, parent, childrenOf(value.props), frame, false);
+ }
+ return createFiberFromElement(builder, parent, value, frame);
+ case "array":
+ return reconcileArray(builder, parent, value.items, frame);
+ case "list":
+ return [createListNode(builder, parent, value, frame)];
+ default:
+ return createChild(builder, parent, value, frame);
+ }
+};
+
+const reconcileArray = (
+ builder: Builder,
+ parent: StaticFiber,
+ items: StaticValue[],
+ frame: Frame,
+): StaticNode[] => items.flatMap((item) => createChild(builder, parent, item, frame));
+
+/**
+ * `createChild`: one entry of a children array. Nested arrays and fragments
+ * become `Fragment` fibers, text becomes `HostText`, and `null`/booleans/`""`
+ * render nothing.
+ */
+const createChild = (
+ builder: Builder,
+ parent: StaticFiber,
+ value: StaticValue,
+ frame: Frame,
+): StaticNode[] => {
+ if (isOverBudget(builder)) return [unknownNode(builder, "fiber budget exceeded")];
+ switch (value.kind) {
+ case "literal": {
+ const primitive = value.value;
+ if (typeof primitive === "string")
+ return primitive === "" ? [] : [createTextFiber(builder, parent, primitive)];
+ if (typeof primitive === "number" || typeof primitive === "bigint") {
+ return [createTextFiber(builder, parent, String(primitive))];
+ }
+ return [];
+ }
+ case "text":
+ return branch(`${value.description} !== ""`, [[createTextFiber(builder, parent, null)], []]);
+ case "conditional":
+ return branch(value.test, [
+ createChild(builder, parent, value.whenTrue, frame),
+ createChild(builder, parent, value.whenFalse, frame),
+ ]);
+ case "optional":
+ return branch(value.test, [createChild(builder, parent, value.value, frame), []]);
+ case "element":
+ return createFiberFromElement(builder, parent, value, frame);
+ case "array":
+ return [createArrayFragment(builder, parent, value.items, frame)];
+ case "list": {
+ if (value.isInline) return [createListNode(builder, parent, value, frame)];
+ const fiber = createFragmentFiber(builder, parent);
+ fiber.children = [createListNode(builder, fiber, value, frame)];
+ return [fiber];
+ }
+ case "unknown":
+ return [unknownNode(builder, value.description)];
+ default:
+ return [unknownNode(builder, `child ${describeValue(value)}`)];
+ }
+};
+
+/** Nested arrays and iterables reconcile as a keyless `Fragment` fiber owned by the parent. */
+const createFragmentFiber = (builder: Builder, parent: StaticFiber): StaticFiber =>
+ createFiber(builder, parent, { tag: "Fragment", name: null, element: null, owner: parent });
+
+const createArrayFragment = (
+ builder: Builder,
+ parent: StaticFiber,
+ items: StaticValue[],
+ frame: Frame,
+): StaticFiber => {
+ const fiber = createFragmentFiber(builder, parent);
+ fiber.children = reconcileArray(builder, fiber, items, frame);
+ return fiber;
+};
+
+const createListNode = (
+ builder: Builder,
+ parent: StaticFiber,
+ value: ListValue,
+ frame: Frame,
+): ListNode => {
+ const items =
+ value.isFlat && value.item.kind === "array"
+ ? reconcileArray(builder, parent, value.item.items, frame)
+ : createChild(builder, parent, value.item, frame);
+ return { kind: "list", description: value.description, items };
+};
+
+const createOpaqueFiber = (
+ builder: Builder,
+ parent: StaticFiber,
+ element: ElementValue,
+ name: string | null,
+ reason: string,
+): StaticFiber => {
+ const fiber = createFiber(builder, parent, { tag: null, name, element, owner: element.owner });
+ fiber.annotations.push("opaque");
+ fiber.children = [unknownNode(builder, reason)];
+ return fiber;
+};
+
+/** `createFiberFromTypeAndProps`: the element type decides the fiber's work tag. */
+const createFiberFromElement = (
+ builder: Builder,
+ parent: StaticFiber,
+ element: ElementValue,
+ frame: Frame,
+): StaticNode[] => {
+ const type = element.type;
+ switch (type.kind) {
+ case "literal":
+ if (typeof type.value === "string") {
+ return [createHostFiber(builder, parent, element, type.value, frame)];
+ }
+ return [
+ createOpaqueFiber(builder, parent, element, null, `element type ${describeValue(type)}`),
+ ];
+ case "function":
+ return [createFunctionComponentFiber(builder, parent, element, type, frame)];
+ case "component":
+ return createFiberFromDefinition(builder, parent, element, type.definition, frame);
+ case "conditional":
+ return branch(type.test, [
+ createFiberFromElement(builder, parent, { ...element, type: type.whenTrue }, frame),
+ createFiberFromElement(builder, parent, { ...element, type: type.whenFalse }, frame),
+ ]);
+ case "external":
+ return [
+ createOpaqueFiber(
+ builder,
+ parent,
+ element,
+ type.name,
+ `implementation of ${type.name ?? type.specifier}`,
+ ),
+ ];
+ default:
+ return [createOpaqueFiber(builder, parent, element, getValueName(type), describeValue(type))];
+ }
+};
+
+const createHostFiber = (
+ builder: Builder,
+ parent: StaticFiber,
+ element: ElementValue,
+ tagName: string,
+ frame: Frame,
+): StaticFiber => {
+ const tag = getHostWorkTag(tagName, element.props, frame.isInsideSvg);
+ const fiber = createFiber(builder, parent, { tag, name: tagName, element, owner: element.owner });
+ if (tag === "HostHoistable" || shouldSetTextContent(tagName, element.props)) return fiber;
+ const childFrame =
+ tagName === "svg"
+ ? { ...frame, isInsideSvg: true }
+ : tagName === "foreignObject"
+ ? { ...frame, isInsideSvg: false }
+ : frame;
+ fiber.children = reconcileHostChildren(builder, fiber, childrenOf(element.props), childFrame);
+ return fiber;
+};
+
+/** A lone string child is written as `textContent`, so it produces no fiber. */
+const reconcileHostChildren = (
+ builder: Builder,
+ parent: StaticFiber,
+ value: StaticValue,
+ frame: Frame,
+): StaticNode[] => {
+ if (isDirectTextChild(value)) return [];
+ if (value.kind === "conditional") {
+ return branch(value.test, [
+ reconcileHostChildren(builder, parent, value.whenTrue, frame),
+ reconcileHostChildren(builder, parent, value.whenFalse, frame),
+ ]);
+ }
+ return reconcileChildren(builder, parent, value, frame);
+};
+
+const createRenderContext = (
+ builder: Builder,
+ fiber: StaticFiber,
+ module: FunctionValue["module"],
+ frame: Frame,
+): EvaluationContext => ({
+ ...builder.interpreter.createModuleContext(module),
+ owner: fiber,
+ hooks: fiber.hooks,
+ contexts: frame.contexts,
+});
+
+/** Evaluates a component body and reconciles what it returned under `fiber`. */
+const renderInto = (
+ builder: Builder,
+ fiber: StaticFiber,
+ render: FunctionValue,
+ callArguments: StaticValue[],
+ frame: Frame,
+): StaticNode[] => {
+ const displayName = fiber.name ?? "anonymous component";
+ if (frame.depth >= builder.options.maxRenderDepth) {
+ builder.interpreter.report(
+ "call-depth",
+ `render depth limit reached at ${displayName}`,
+ render.module,
+ render.fn,
+ );
+ return [unknownNode(builder, `render depth limit at ${displayName}`)];
+ }
+ const recursion = frame.renderStack.filter((entry) => entry === render.fn).length;
+ if (recursion >= builder.options.maxRecursion) {
+ return [unknownNode(builder, `recursive render of ${displayName}`)];
+ }
+ const context = createRenderContext(builder, fiber, render.module, frame);
+ const result = builder.interpreter.callFunction(render, callArguments, context);
+ const childFrame: Frame = {
+ ...frame,
+ depth: frame.depth + 1,
+ renderStack: [...frame.renderStack, render.fn],
+ };
+ return reconcileChildren(builder, fiber, result, childFrame);
+};
+
+const createFunctionComponentFiber = (
+ builder: Builder,
+ parent: StaticFiber,
+ element: ElementValue,
+ fn: FunctionValue,
+ frame: Frame,
+ tag: WorkTagName = "FunctionComponent",
+): StaticFiber => {
+ const fiber = createFiber(builder, parent, { tag, name: fn.name, element, owner: element.owner });
+ fiber.children = renderInto(builder, fiber, fn, [element.props], frame);
+ return fiber;
+};
+
+const createFiberFromDefinition = (
+ builder: Builder,
+ parent: StaticFiber,
+ element: ElementValue,
+ definition: ComponentDefinition,
+ frame: Frame,
+): StaticNode[] => {
+ const name = getComponentName(definition);
+ const owner = element.owner;
+ switch (definition.kind) {
+ case "builtin":
+ return [createBuiltinFiber(builder, parent, element, definition.name, frame)];
+ case "class": {
+ const fiber = createFiber(builder, parent, { tag: "ClassComponent", name, element, owner });
+ if (definition.isErrorBoundary) fiber.annotations.push("error boundary");
+ const context = createRenderContext(builder, fiber, definition.module, frame);
+ const props = resolveClassProps(definition, element.props);
+ const { render } = instantiateClassComponent(builder.interpreter, definition, props, context);
+ fiber.children = render
+ ? renderInto(builder, fiber, render, [], frame)
+ : [unknownNode(builder, `render() of ${name ?? "class component"}`)];
+ return [fiber];
+ }
+ case "memo": {
+ const inner = definition.inner;
+ if (inner.kind === "conditional") {
+ return branch(
+ inner.test,
+ [inner.whenTrue, inner.whenFalse].map((arm) =>
+ createFiberFromDefinition(
+ builder,
+ parent,
+ element,
+ { ...definition, inner: arm },
+ frame,
+ ),
+ ),
+ );
+ }
+ if (inner.kind === "function" && !definition.hasCompare) {
+ return [
+ createFunctionComponentFiber(
+ builder,
+ parent,
+ element,
+ inner,
+ frame,
+ "SimpleMemoComponent",
+ ),
+ ];
+ }
+ const fiber = createFiber(builder, parent, { tag: "MemoComponent", name, element, owner });
+ fiber.children = createFiberFromElement(
+ builder,
+ fiber,
+ { ...element, type: inner, key: null, owner: fiber },
+ frame,
+ );
+ return [fiber];
+ }
+ case "forwardRef": {
+ const fiber = createFiber(builder, parent, { tag: "ForwardRef", name, element, owner });
+ if (!definition.render) {
+ fiber.children = [unknownNode(builder, `render of ${name ?? "forwardRef"}`)];
+ return [fiber];
+ }
+ const props = cloneObject(element.props);
+ const ref = props.properties.get("ref") ?? NULL;
+ props.properties.delete("ref");
+ fiber.children = renderInto(builder, fiber, definition.render, [props, ref], frame);
+ return [fiber];
+ }
+ case "lazy": {
+ const nodes = createFiberFromElement(
+ builder,
+ parent,
+ { ...element, type: definition.inner },
+ frame,
+ );
+ for (const node of nodes) if (node.kind === "fiber") node.annotations.push("lazy");
+ return nodes;
+ }
+ case "context":
+ return [createContextFiber(builder, parent, element, definition, frame)];
+ }
+};
+
+const createContextFiber = (
+ builder: Builder,
+ parent: StaticFiber,
+ element: ElementValue,
+ definition: Extract,
+ frame: Frame,
+): StaticFiber => {
+ const baseName = definition.name ?? "Context";
+ if (definition.role === "provider") {
+ const fiber = createFiber(builder, parent, {
+ tag: "ContextProvider",
+ name: `${baseName}.Provider`,
+ element,
+ owner: element.owner,
+ });
+ const value = getObjectProperty(element.props, "value");
+ const childFrame = { ...frame, contexts: provideContext(frame.contexts, definition, value) };
+ fiber.children = reconcileChildren(builder, fiber, childrenOf(element.props), childFrame);
+ return fiber;
+ }
+ const fiber = createFiber(builder, parent, {
+ tag: "ContextConsumer",
+ name: `${baseName}.Consumer`,
+ element,
+ owner: element.owner,
+ });
+ const render = childrenOf(element.props);
+ const contextValue = readContext(frame.contexts, component(definition));
+ fiber.children =
+ render.kind === "function"
+ ? renderInto(builder, fiber, render, [contextValue], frame)
+ : [unknownNode(builder, `consumer render ${describeValue(render)}`)];
+ return fiber;
+};
+
+const createOffscreenFiber = (
+ builder: Builder,
+ parent: StaticFiber,
+ children: StaticValue,
+ mode: string,
+ frame: Frame,
+): StaticFiber => {
+ const fiber = createFiber(builder, parent, {
+ tag: "OffscreenComponent",
+ name: "Offscreen",
+ element: null,
+ owner: null,
+ });
+ fiber.annotations.push(`mode=${mode}`);
+ fiber.children = reconcileChildren(builder, fiber, children, frame);
+ return fiber;
+};
+
+/**
+ * Built-in element types. `Suspense` and `Activity` mount their children
+ * inside an `Offscreen` fiber (`mountSuspensePrimaryChildren`,
+ * `mountActivityChildren`); the others reconcile children directly.
+ */
+const createBuiltinFiber = (
+ builder: Builder,
+ parent: StaticFiber,
+ element: ElementValue,
+ name: BuiltinComponentName,
+ frame: Frame,
+): StaticFiber => {
+ const props = element.props;
+ const fiber = createFiber(builder, parent, {
+ tag: getBuiltinWorkTag(name),
+ name: BUILTIN_FIBER_NAMES[name],
+ element,
+ owner: element.owner,
+ });
+ switch (name) {
+ case "Suspense":
+ fiber.children = [createOffscreenFiber(builder, fiber, childrenOf(props), "visible", frame)];
+ fiber.fallback = reconcileChildren(
+ builder,
+ fiber,
+ getObjectProperty(props, "fallback"),
+ frame,
+ );
+ return fiber;
+ case "Activity": {
+ const mode = getObjectProperty(props, "mode");
+ const modeName =
+ mode.kind === "literal" && typeof mode.value === "string" ? mode.value : "visible";
+ fiber.children = [createOffscreenFiber(builder, fiber, childrenOf(props), modeName, frame)];
+ return fiber;
+ }
+ default:
+ fiber.children = reconcileChildren(builder, fiber, childrenOf(props), frame);
+ return fiber;
+ }
+};
+
+const getBuiltinWorkTag = (name: BuiltinComponentName): WorkTagName => {
+ switch (name) {
+ case "Fragment":
+ return "Fragment";
+ case "Suspense":
+ return "SuspenseComponent";
+ case "SuspenseList":
+ return "SuspenseListComponent";
+ case "StrictMode":
+ return "Mode";
+ case "Profiler":
+ return "Profiler";
+ case "Activity":
+ return "ActivityComponent";
+ case "ViewTransition":
+ return "ViewTransitionComponent";
+ case "Portal":
+ return "HostPortal";
+ }
+};
+
+const DEFAULT_OPTIONS: Required = {
+ maxRenderDepth: 64,
+ maxRecursion: 8,
+ maxFiberCount: 50_000,
+};
+
+/** Builds the fiber tree `root.render(value)` would commit, under a `HostRoot`. */
+export const buildStaticTree = (
+ interpreter: Interpreter,
+ value: StaticValue,
+ options: BuildOptions = {},
+): StaticRoot => {
+ const builder: Builder = {
+ interpreter,
+ options: { ...DEFAULT_OPTIONS, ...options },
+ fiberCount: 0,
+ unknownCount: 0,
+ };
+ const root = createFiber(builder, null, {
+ tag: "HostRoot",
+ name: null,
+ element: null,
+ owner: null,
+ });
+ root.children = reconcileChildren(builder, root, value, ROOT_FRAME);
+ return { root, fiberCount: builder.fiberCount, unknownCount: builder.unknownCount };
+};
+
+/** Builds the tree for `` rendered at the root. */
+export const buildComponentTree = (
+ interpreter: Interpreter,
+ componentValue: StaticValue,
+ props: ObjectValue = object(),
+ options: BuildOptions = {},
+): StaticRoot => {
+ const element: ElementValue = {
+ kind: "element",
+ type: componentValue,
+ key: null,
+ props,
+ location: null,
+ owner: null,
+ };
+ if (componentValue.kind === "unknown") {
+ return buildStaticTree(interpreter, unknown(`root ${componentValue.description}`), options);
+ }
+ return buildStaticTree(interpreter, element, options);
+};
diff --git a/packages/parser/src/fiber/host.ts b/packages/parser/src/fiber/host.ts
new file mode 100644
index 00000000..66e29035
--- /dev/null
+++ b/packages/parser/src/fiber/host.ts
@@ -0,0 +1,117 @@
+import {
+ getObjectProperty,
+ getTruthiness,
+ isNullish,
+ type ObjectValue,
+ type StaticValue,
+} from "../analyze/values.js";
+import type { WorkTagName } from "./types.js";
+
+/** `typeof value === "string"` when statically decidable; unknown values are assumed strings. */
+const isStringLike = (value: StaticValue): boolean => {
+ switch (value.kind) {
+ case "literal":
+ return typeof value.value === "string";
+ case "text":
+ case "unknown":
+ return true;
+ case "conditional":
+ return isStringLike(value.whenTrue) && isStringLike(value.whenFalse);
+ default:
+ return false;
+ }
+};
+
+const isNonEmptyString = (value: StaticValue): boolean =>
+ isStringLike(value) && !(value.kind === "literal" && value.value === "");
+
+const isTruthy = (value: StaticValue): boolean => getTruthiness(value) !== false;
+
+const isDefined = (value: StaticValue): boolean =>
+ !(value.kind === "literal" && isNullish(value.value));
+
+/**
+ * `shouldSetTextContent` from `ReactFiberConfigDOM`: a lone string child is
+ * written as `textContent` and gets no `HostText` fiber. Children that are
+ * strings only on some paths are handled by the reconciler's branch logic.
+ */
+export const shouldSetTextContent = (tagName: string, props: ObjectValue): boolean => {
+ if (tagName === "textarea" || tagName === "noscript") return true;
+ const innerHtml = props.properties.get("dangerouslySetInnerHTML");
+ if (!innerHtml) return false;
+ if (innerHtml.kind === "object") return isDefined(getObjectProperty(innerHtml, "__html"));
+ return innerHtml.kind !== "literal";
+};
+
+/** Whether a child value written directly as `children` is set as text content. */
+export const isDirectTextChild = (value: StaticValue): boolean => {
+ switch (value.kind) {
+ case "literal":
+ return (
+ typeof value.value === "string" ||
+ typeof value.value === "number" ||
+ typeof value.value === "bigint"
+ );
+ case "text":
+ return true;
+ default:
+ return false;
+ }
+};
+
+export const isHostSingletonType = (tagName: string): boolean =>
+ tagName === "html" || tagName === "head" || tagName === "body";
+
+/**
+ * `isHostHoistableType` from `ReactFiberConfigDOM`: resources React hoists
+ * into `` and manages without reconciler children.
+ */
+export const isHostHoistableType = (
+ tagName: string,
+ props: ObjectValue,
+ isInsideSvg: boolean,
+): boolean => {
+ if (isInsideSvg || isDefined(getObjectProperty(props, "itemProp"))) return false;
+ const prop = (name: string): StaticValue => getObjectProperty(props, name);
+ switch (tagName) {
+ case "meta":
+ case "title":
+ return true;
+ case "style":
+ return isStringLike(prop("precedence")) && isNonEmptyString(prop("href"));
+ case "link": {
+ if (
+ !isStringLike(prop("rel")) ||
+ !isNonEmptyString(prop("href")) ||
+ isTruthy(prop("onLoad")) ||
+ isTruthy(prop("onError"))
+ ) {
+ return false;
+ }
+ const rel = prop("rel");
+ if (rel.kind === "literal" && rel.value === "stylesheet") {
+ return isStringLike(prop("precedence")) && !isDefined(prop("disabled"));
+ }
+ return rel.kind === "literal";
+ }
+ case "script":
+ return (
+ getTruthiness(prop("async")) === true &&
+ !isTruthy(prop("onLoad")) &&
+ !isTruthy(prop("onError")) &&
+ isNonEmptyString(prop("src")) &&
+ prop("src").kind !== "unknown"
+ );
+ default:
+ return false;
+ }
+};
+
+export const getHostWorkTag = (
+ tagName: string,
+ props: ObjectValue,
+ isInsideSvg: boolean,
+): WorkTagName => {
+ if (isHostHoistableType(tagName, props, isInsideSvg)) return "HostHoistable";
+ return isHostSingletonType(tagName) ? "HostSingleton" : "HostComponent";
+};
diff --git a/packages/parser/src/fiber/index.ts b/packages/parser/src/fiber/index.ts
new file mode 100644
index 00000000..9772fdb7
--- /dev/null
+++ b/packages/parser/src/fiber/index.ts
@@ -0,0 +1,3 @@
+export * from "./build.js";
+export * from "./host.js";
+export * from "./types.js";
diff --git a/packages/parser/src/fiber/types.ts b/packages/parser/src/fiber/types.ts
new file mode 100644
index 00000000..c740896b
--- /dev/null
+++ b/packages/parser/src/fiber/types.ts
@@ -0,0 +1,83 @@
+import type { HookCall } from "../analyze/hooks.js";
+import type { ObjectValue, StaticValue } from "../analyze/values.js";
+import type { SourceLocation } from "../module/location.js";
+
+/**
+ * Names of React's fiber work tags (`ReactWorkTags.js`). Names rather than
+ * numbers because the numbering differs between React versions; the runtime
+ * harness maps numeric tags back to these names per renderer.
+ */
+export type WorkTagName =
+ | "FunctionComponent"
+ | "ClassComponent"
+ | "HostRoot"
+ | "HostPortal"
+ | "HostComponent"
+ | "HostText"
+ | "Fragment"
+ | "Mode"
+ | "ContextConsumer"
+ | "ContextProvider"
+ | "ForwardRef"
+ | "Profiler"
+ | "SuspenseComponent"
+ | "MemoComponent"
+ | "SimpleMemoComponent"
+ | "SuspenseListComponent"
+ | "OffscreenComponent"
+ | "HostHoistable"
+ | "HostSingleton"
+ | "ViewTransitionComponent"
+ | "ActivityComponent";
+
+export interface StaticFiber {
+ kind: "fiber";
+ /** `null` when the component's implementation is outside the analyzed graph. */
+ tag: WorkTagName | null;
+ name: string | null;
+ key: string | null;
+ /** Text content for `HostText`; `null` when only known at runtime. */
+ text: string | null;
+ props: ObjectValue | null;
+ /** The element type that created this fiber (`fiber.type`). */
+ type: StaticValue | null;
+ /** Where the element that created this fiber was written. */
+ location: SourceLocation | null;
+ /** Component fiber whose render created this fiber's element (`_debugOwner`). */
+ owner: StaticFiber | null;
+ parent: StaticFiber | null;
+ children: StaticNode[];
+ hooks: HookCall[];
+ /** Display-only facts such as `mode=hidden`, `lazy` or `use client`. */
+ annotations: string[];
+ /** Suspense fallback subtree, mounted instead of `children` while suspended. */
+ fallback: StaticNode[] | null;
+}
+
+/** One of several possible child sequences, decided at runtime. */
+export interface BranchNode {
+ kind: "branch";
+ test: string;
+ alternatives: StaticNode[][];
+}
+
+/** Zero or more repetitions of `items`, the shape produced by `.map()`. */
+export interface ListNode {
+ kind: "list";
+ description: string;
+ items: StaticNode[];
+}
+
+/** Children the analysis could not determine; matches any runtime subtree. */
+export interface UnknownNode {
+ kind: "unknown";
+ description: string;
+}
+
+export type StaticNode = StaticFiber | BranchNode | ListNode | UnknownNode;
+
+export interface StaticRoot {
+ root: StaticFiber;
+ fiberCount: number;
+ unknownCount: number;
+}
diff --git a/packages/parser/src/harness/index.ts b/packages/parser/src/harness/index.ts
new file mode 100644
index 00000000..06c8efd7
--- /dev/null
+++ b/packages/parser/src/harness/index.ts
@@ -0,0 +1,3 @@
+export * from "./render.js";
+export * from "./runtime-snapshot.js";
+export * from "./verify.js";
diff --git a/packages/parser/src/harness/render.ts b/packages/parser/src/harness/render.ts
new file mode 100644
index 00000000..d121d511
--- /dev/null
+++ b/packages/parser/src/harness/render.ts
@@ -0,0 +1,62 @@
+import { type FiberRoot, instrument } from "bippy";
+import { act, type ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import type { FiberSnapshot } from "../snapshot/types.js";
+import { snapshotRuntimeFiber } from "./runtime-snapshot.js";
+
+export interface RuntimeHarness {
+ container: HTMLElement;
+ /** Renders and returns the committed tree, resolving effects and suspended lazies first. */
+ render: (children: ReactNode) => Promise;
+ unmount: () => Promise;
+}
+
+/** How long to keep flushing after a commit so lazies and suspended data can settle. */
+const SETTLE_ROUNDS = 8;
+
+/**
+ * Renders into a detached DOM container with Bippy observing commits. The
+ * hook must already be installed (`bippy/install-hook-only`) before React
+ * DOM is imported, which the test setup does.
+ */
+export const createRuntimeHarness = (): RuntimeHarness => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ let committedRoot: FiberRoot | null = null;
+ const unsubscribe = instrument({
+ onCommitFiberRoot: (_rendererId, root) => {
+ committedRoot = root;
+ },
+ });
+ const root = createRoot(container);
+ return {
+ container,
+ render: async (children) => {
+ await act(async () => root.render(children));
+ for (let round = 0; round < SETTLE_ROUNDS; round++) {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ }
+ if (!committedRoot) throw new Error("React did not commit through Bippy's hook");
+ return snapshotRuntimeFiber(committedRoot.current);
+ },
+ unmount: async () => {
+ try {
+ await act(async () => root.unmount());
+ } finally {
+ unsubscribe();
+ container.remove();
+ }
+ },
+ };
+};
+
+export const renderRuntimeSnapshot = async (children: ReactNode): Promise => {
+ const harness = createRuntimeHarness();
+ try {
+ return await harness.render(children);
+ } finally {
+ await harness.unmount();
+ }
+};
diff --git a/packages/parser/src/harness/runtime-snapshot.ts b/packages/parser/src/harness/runtime-snapshot.ts
new file mode 100644
index 00000000..75d6f116
--- /dev/null
+++ b/packages/parser/src/harness/runtime-snapshot.ts
@@ -0,0 +1,152 @@
+import { type Fiber, getDisplayName, getReactWorkTagsForFiber, type ReactWorkTagMap } from "bippy";
+import type { WorkTagName } from "../fiber/types.js";
+import type { FiberSnapshot } from "../snapshot/types.js";
+
+const WORK_TAG_NAMES: ReadonlySet = new Set([
+ "FunctionComponent",
+ "ClassComponent",
+ "HostRoot",
+ "HostPortal",
+ "HostComponent",
+ "HostText",
+ "Fragment",
+ "Mode",
+ "ContextConsumer",
+ "ContextProvider",
+ "ForwardRef",
+ "Profiler",
+ "SuspenseComponent",
+ "MemoComponent",
+ "SimpleMemoComponent",
+ "SuspenseListComponent",
+ "OffscreenComponent",
+ "HostHoistable",
+ "HostSingleton",
+ "ViewTransitionComponent",
+ "ActivityComponent",
+]);
+
+const isWorkTagName = (name: string): name is WorkTagName => WORK_TAG_NAMES.has(name);
+
+const HOOK_BEARING_TAGS = new Set([
+ "FunctionComponent",
+ "ForwardRef",
+ "SimpleMemoComponent",
+]);
+
+export const getWorkTagName = (fiber: Fiber): WorkTagName | null => {
+ const workTags: ReactWorkTagMap = getReactWorkTagsForFiber(fiber);
+ for (const [name, value] of Object.entries(workTags)) {
+ if (value === fiber.tag && isWorkTagName(name)) return name;
+ }
+ return null;
+};
+
+/** Length of the hook state list (`memoizedState`) on a function-like fiber. */
+const countHooks = (fiber: Fiber, tag: WorkTagName | null): number | null => {
+ if (!HOOK_BEARING_TAGS.has(tag)) return null;
+ let count = 0;
+ let node: unknown = fiber.memoizedState;
+ while (node && typeof node === "object" && "queue" in node && "next" in node) {
+ count++;
+ node = node.next;
+ }
+ return count;
+};
+
+const getRuntimeText = (fiber: Fiber, tag: WorkTagName | null): string | null => {
+ if (tag !== "HostText") return null;
+ const props: unknown = fiber.memoizedProps;
+ return typeof props === "string" ? props : String(props);
+};
+
+const getRuntimeKey = (fiber: Fiber): string | null =>
+ typeof fiber.key === "string" ? fiber.key : null;
+
+/** Names React DevTools gives built-in fibers (`getDisplayNameForFiber`), plus the ones it hides. */
+const BUILTIN_NAMES: Partial> = {
+ Fragment: "Fragment",
+ SuspenseComponent: "Suspense",
+ SuspenseListComponent: "SuspenseList",
+ OffscreenComponent: "Offscreen",
+ ActivityComponent: "Activity",
+ ViewTransitionComponent: "ViewTransition",
+ Profiler: "Profiler",
+ HostPortal: "Portal",
+};
+
+const STRICT_MODE_TYPE = Symbol.for("react.strict_mode");
+
+/** esbuild names a class it lowers `_a`, `_a2`, …; the source name is gone, so the fiber counts as unnamed. */
+const BUNDLER_TEMPORARY = /^_[a-z]\d*$/;
+
+const getTypeName = (type: unknown): string | null => {
+ const name = getDisplayName(type);
+ return name !== null && BUNDLER_TEMPORARY.test(name) ? null : name;
+};
+
+const getContextName = (type: unknown): string => {
+ const context: unknown =
+ typeof type === "object" && type !== null && "_context" in type ? type._context : type;
+ const displayName: unknown =
+ typeof context === "object" && context !== null && "displayName" in context
+ ? context.displayName
+ : null;
+ return typeof displayName === "string" && displayName ? displayName : "Context";
+};
+
+const getRuntimeName = (fiber: Fiber, tag: WorkTagName | null): string | null => {
+ switch (tag) {
+ case "HostText":
+ case "HostRoot":
+ return null;
+ case "Mode":
+ return fiber.type === STRICT_MODE_TYPE ? "StrictMode" : "Mode";
+ case "ContextProvider":
+ return `${getContextName(fiber.type)}.Provider`;
+ case "ContextConsumer":
+ return `${getContextName(fiber.type)}.Consumer`;
+ default:
+ return (tag && BUILTIN_NAMES[tag]) ?? getTypeName(fiber.type);
+ }
+};
+
+interface RuntimeSnapshotState {
+ nextId: number;
+ /** Fibers and their alternates, since `_debugOwner` may point at either version. */
+ ids: Map